diff --git a/.github/workflows/content.yml b/.github/workflows/content.yml index 83561c31..04d80871 100644 --- a/.github/workflows/content.yml +++ b/.github/workflows/content.yml @@ -28,6 +28,16 @@ jobs: - name: Validate site content run: python scripts/validate_site.py + # 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. + - name: Voice scan (ban tier) + run: bash scripts/voice-scan.sh + + # Always passes: logs the all-tier totals, so a review can compare them with a local run. + - name: Voice scan totals (all tiers) + run: bash scripts/voice-scan.sh --all-tiers-summary + # The one check branch protection requires: passes when every job above # succeeded or was skipped. content-ok: diff --git a/CLAUDE.md b/CLAUDE.md index 943760ec..d6df570b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,13 +29,13 @@ 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) | +| `content-ok` | `content.yml` | `scripts/validate_site.py` (the "Validate before commit" rules) + voice scan, ban tier (`scripts/voice-scan.sh`) | | `examples-ok` | `examples.yml` | build matrix per changed example project | Workflows trigger on `push: branches: [main]` plus `pull_request:` (no `paths:` filters). `examples.yml` selects projects with `scripts/ci_changed_projects.py`, which you can run -locally (`--base origin/main`). Run `python3 scripts/validate_site.py` before committing doc -changes. Work on a branch, open a PR, wait for the three checks, squash-merge. +locally (`--base origin/main`). Run `python3 scripts/validate_site.py` and `scripts/voice-scan.sh` before +committing doc changes. Work on a branch, open a PR, wait for the three checks, squash-merge. ## Example layout @@ -189,6 +189,16 @@ k6, Locust. - **Validate before commit:** front-matter parses; raw-guard balance; **0 unguarded `{{`**; each codetab block-count == its `langs` count; referenced figures exist (`.svg` + `.excalidraw`); **0 positional terms**. +- **Professional voice:** `scripts/voice-scan.sh` must report 0 ban-tier hits (CI gate in + `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 + 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). ## Workflow in Claude Code (changed from claude.ai) diff --git a/README.md b/README.md index 44efd6a9..5034c200 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ cd python && docker compose down -v | `_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 | -| `_plans/` | the roadmap and the Phase-3 backlog | +| `_plans/` | internal planning notes | ## Deploy diff --git a/_docs/00-setting-up.md b/_docs/00-setting-up.md index d3adda6b..c6b5677d 100644 --- a/_docs/00-setting-up.md +++ b/_docs/00-setting-up.md @@ -2,7 +2,7 @@ title: "Setting up · Patterns in the age of AI" order: 0 part: "Setting up" -description: "Why a shared vocabulary of patterns matters more now that an AI writes the first draft, and the canonical five-service system we design against for the rest of the book." +description: "Why a shared vocabulary of patterns matters more now that an AI writes the first draft, and the canonical five-service system we design against throughout the book." duration: 10 minutes --- @@ -21,13 +21,13 @@ amplifier; the patterns decide which direction it amplifies. ## Four maxims to carry throughout -Every concrete choice in this book is a deliberate exercise in four ideas. Keep +Every concrete choice in this book applies four ideas. Keep them in view: - **The least-worst decision.** Every architecture choice is a set of trades — latency for throughput, consistency for availability, simplicity for flexibility. The job is not to find the option with no downside; it is to make - the trades visible and deliberate. + the trades visible and explicit. - **Risk management.** You manage the *distribution* of failures, not their existence. Failure doesn't go away; you choose where it lands and how it's contained. @@ -36,8 +36,8 @@ them in view: operational complexity. The question is whether you are buying the right thing for what you are paying. - **No problem cannot be made worse.** A confidently-wrong intervention amplifies - the original pain. AI tooling makes confident interventions cheap and easy, - which is exactly why this maxim earns its place. + the original pain. AI tooling makes confident interventions cheap, + which is exactly why this maxim is needed. ## Three layers of patterns literacy @@ -88,9 +88,9 @@ When a later chapter shows the transactional outbox, or content-based routing, o a saga, it is wiring up *these* services. Keeping one concrete system in mind is what stops the patterns from staying abstract. -## How the rest of the book is ordered +## How the book is ordered -The order is deliberate: principles first, because they justify every later +The order follows the dependencies: principles first, because they justify every later choice; then the synchronous surface (communications, composition); then data ownership; then the asynchronous backbone (events, streams); then the operational concerns (workflows and the platform planes); and finally anti-patterns as a diff --git a/_docs/01-cloud-native-principles.md b/_docs/01-cloud-native-principles.md index 9e959f97..fbf7d5c6 100644 --- a/_docs/01-cloud-native-principles.md +++ b/_docs/01-cloud-native-principles.md @@ -30,7 +30,7 @@ Read each as a behaviour you design toward, not a product you install: - **Automatable** — declarative config, GitOps, self-healing. - **Loosely coupled** — contracts not calls, async where it fits. -These six are the rubric for the rest of the book. Hold each design decision up +These six are the rubric for every pattern in this book. Hold each design decision up to them. ## Lean on the platform @@ -49,7 +49,7 @@ 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 its generated stubs, never against your database. That -is what actually delivers independent deployability: the contract *is* the +is what delivers independent deployability: the contract *is* the service boundary. Skip it and you get distributed coupling — the worst of both worlds. @@ -66,7 +66,7 @@ Here are all twelve, one line each. The useful way to read it today is not to recite all twelve, but to split them by **who owns each one** — the few you still write, versus the many the platform now -provides for free. +provides built in. {% include excalidraw.html file="01-twelve-factor-ownership" @@ -100,7 +100,7 @@ group is treated in depth — most of the twelve are now adopted, not authored. ## A twelve-factor service, ready for the platform -Two factors dominate the code you actually write: **config from the environment** +Two factors dominate the code you write: **config from the environment** (factor III) and **health and readiness probes** the platform calls. Here it is in each stack — pick your tab. diff --git a/_docs/02-communications.md b/_docs/02-communications.md index df7d190d..7ab4cb77 100644 --- a/_docs/02-communications.md +++ b/_docs/02-communications.md @@ -18,19 +18,19 @@ 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, 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." 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 request/response — the caller waits — while events are fire-and-forget. Three of the four couple the caller to the callee's availability for the duration of the call; only the fourth breaks that coupling. Choosing a style is therefore choosing how much -availability you are willing to inherit — the thread the rest of the chapter pulls on. +availability you are willing to inherit — the thread that runs through this chapter. REST wins where reach and caching matter; gRPC where an internal hop must be fast and strongly typed; GraphQL where one client read would otherwise be a handful of round trips; and events wherever immediacy is not strictly required. -Two interaction styles are deliberately deferred from this chapter. **GraphQL** gets +Two interaction styles are deferred from this chapter. **GraphQL** gets its own treatment in **03 · Composition**, because GraphQL is fundamentally a composition pattern — a gateway that fans out to REST and gRPC back-ends — and that context matters more than the wire format. **WebSockets** are covered in @@ -214,10 +214,10 @@ func (s *Server) routes() { } ``` -The `events.publish("order.placed", …)` line is shown simply here so the focus +The `events.publish("order.placed", …)` line is shown in simplified form here so the focus stays on the edge concerns; **04 · Data** makes that emission transactionally safe with the outbox. FastAPI, Spring, and the others also emit an OpenAPI document -for free, which becomes the registered contract. +automatically, which becomes the registered contract. ## gRPC for internal calls diff --git a/_docs/03-composition.md b/_docs/03-composition.md index a67f1063..c31af32c 100644 --- a/_docs/03-composition.md +++ b/_docs/03-composition.md @@ -227,7 +227,7 @@ domains must present as one graph and reads dominate. The anti-pattern to name explicitly is the opposite of all three: **service-to-service call chains**, where `order` calls `inventory` calls `pricing` calls… — synchronous, deeply coupled, and impossible to reason about. It -quietly rebuilds a distributed monolith. Composition belongs at the edge, not +rebuilds a distributed monolith. Composition belongs at the edge, not buried in a chain of internal calls. ## Composition through the coupling lens @@ -265,7 +265,7 @@ exactly why it becomes a distributed monolith. The gateway is just HTTP. Send the same query with `curl` or Postman and request `stock` in one call, then omit it in another — confirm the gateway only calls -`inventory` when `stock` is actually selected. Then request a *list* of orders with +`inventory` when `stock` is selected. Then request a *list* of orders with `stock` and watch the back-end call count: without batching it climbs with the list size; with a DataLoader it stays flat. That flat line is the N+1 fix working. diff --git a/_docs/04-data.md b/_docs/04-data.md index dbf218f4..4f8fbe38 100644 --- a/_docs/04-data.md +++ b/_docs/04-data.md @@ -22,7 +22,7 @@ one atomic act. The first rule of data in a microservice system is the one most often broken: **each service owns its data, and no other service touches its tables.** Peers reach an owned dataset only through the owning service's API, never by connecting to its -database. Sharing a database may look like reuse, but it silently couples every +database. Sharing a database may look like reuse, but it couples every consumer to the owner's schema — a column rename becomes a cross-team migration, and two services' deploys can no longer move independently. A shared database is the single most common way a nominally microservice system collapses back into a @@ -30,7 +30,7 @@ distributed monolith. {% include excalidraw.html file="04-db-per-service" - alt="Four services — order, inventory, payment, shipping — each owning its data, each backed by its own PostgreSQL with a private schema run by CloudNativePG. A band below reads: polyglot persistence, pick the store per workload (relational, key-value, search, object)." + alt="Four services — order, inventory, payment, shipping — each owning its data, each backed by its own PostgreSQL with a private schema run by CloudNativePG. A banner reads: polyglot persistence, pick the store per workload (relational, key-value, search, object)." caption="Figure 4.1 — A database per service: private schemas, reached only through the owning API" %} Ownership also frees each service to pick the store that fits its workload — @@ -53,7 +53,7 @@ are linked by a stream of changes flowing from the write side to the read side. The cost is eventual consistency — a small lag between the write committing and the read model catching up. The benefit is that each side can scale and evolve -independently. Reach for it when reads and writes genuinely have different +independently. Reach for it when reads and writes have different shapes and access patterns; skip it when a single store handles both happily, because the separation is not free. @@ -104,7 +104,7 @@ Kubernetes the rest of the stack runs on. > **WAL — Write-Ahead Log.** The database's commit-ordered, durable log. It > already exists so the database can replicate and recover from a crash; -> Debezium simply subscribes to it. +> Debezium subscribes to it. ## The dual-write bug, and the outbox that fixes it @@ -128,7 +128,7 @@ guaranteed, ordered consequence of it. ### The code, in your language -Pick your stack — the tab selection follows you through the rest of the book. The +Pick your stack — the tab selection follows you across all chapters. The shape is identical everywhere: open one local transaction, persist the state change, persist the outbox row in the *same* transaction, commit. No broker call sits on the request path. diff --git a/_docs/05-event-driven.md b/_docs/05-event-driven.md index 7fc6ca2b..0603daae 100644 --- a/_docs/05-event-driven.md +++ b/_docs/05-event-driven.md @@ -27,8 +27,8 @@ on Kubernetes) as the backbone. ## Core benefits — and the costs they carry -Event-driven design buys three things, and the honest version of the story is that -each one comes paired with a cost you then have to design for. The benefits are why +Event-driven design buys three things, and each one comes paired with a cost you +then have to design for. The benefits are why you reach for it; the costs are what separates a system that works from one that pages you at 3 a.m. @@ -42,9 +42,9 @@ Each cost has a standard answer, and all three already appeared in this book: - **Eventual consistency** — the write side commits, the event ships, consumers catch up a moment later. Design reads to tolerate that lag, or read from the write model - when you genuinely need read-your-writes; never assume a consumer is up to date the + when you need read-your-writes; never assume a consumer is up to date the instant the producer commits. This is the same trade the outbox and CQRS sections - make deliberately. + make. - **Debugging complexity** — a single user action becomes a chain of events across several services, so "what happened to order A-1001?" is no longer one log to grep. The fix is to thread a correlation / trace id through every event envelope and lean @@ -248,7 +248,7 @@ identical. ## Schemas are the contract -Events need contracts as much as REST and gRPC do — arguably more, because consumers +Events need contracts as much as REST and gRPC do, and more, because consumers are anonymous and you can't call them to coordinate a change. The schema (Avro, Protobuf, or JSON Schema) lives in a registry; in our stack that is **Apicurio**, run in-cluster. @@ -266,7 +266,7 @@ compatibility policy — **FORWARD** so old consumers tolerate new producers, or **BACKWARD** so new consumers tolerate old data — a change that would break the other side is refused. Best of all, that check runs in CI at publish time: a breaking schema is **rejected before it merges**, not discovered in production when an anonymous -consumer falls over. That enforcement is what makes "add a consumer safely" actually +consumer falls over. That enforcement is what makes "add a consumer safely" safe. ## Event sourcing vs. event streaming @@ -321,8 +321,8 @@ plain Kubernetes as everything else. ## Choosing the substrate: Kafka vs Pulsar vs AMQP The tools landscape split the work into substrate, compute, and movement; the -**substrate** — the durable broker the whole system leans on — is the load-bearing -choice. Three common answers, with genuinely different models rather than cosmetic +**substrate** — the durable broker the whole system leans on — is the most consequential +choice. Three common answers, with different models rather than cosmetic differences: | | **Apache Kafka** | **Apache Pulsar** | **AMQP** (e.g. RabbitMQ) | @@ -347,12 +347,12 @@ Pulsar's pitch over Kafka is mostly **operational**: because serving and storage separate layers, you can scale them independently and tier cold data to object storage, and multi-tenancy and geo-replication are built in rather than bolted on. The price is a larger surface to run — more components and moving parts — which only pays off for -teams that actually need those properties. +teams that need those properties. For this book's running system — `order.placed` as a replayable fact that several services consume independently — a **log** is the right substrate, and Kafka is the sensible default. Reach for **Pulsar** when multi-tenancy, geo, or storage/compute -separation are hard requirements, and for **AMQP** when the job is really a *task +separation are hard requirements, and for **AMQP** when the job is a *task queue* with complex routing rather than an event log you replay. ## Inside the substrates: architecture and what each is best at @@ -377,8 +377,8 @@ offset**, reading at its own position into the log. alt="Producers, keyed by entity, append to a Kafka cluster drawn as a partitioned, replicated log with Partition 0, 1, and 2, each a leader plus in-sync replicas. Two consumer groups read from the cluster — a payment group that tracks its own offsets and an analytics group with independent offsets that can replay. A note: records are retained by time or size, not deleted on read; partitions give parallelism, replicas give durability, and groups read independently." caption="Figure 5.5 — Kafka architecture: a partitioned, replicated log read by independent consumer groups" %} -Its advantages follow from that design. Sequential appends and zero-copy reads push -very high **throughput**; partitions give linear **horizontal scale**; replication +Its advantages follow from that design. Sequential appends and zero-copy reads give +high **throughput**; partitions give linear **horizontal scale**; replication gives **durability**; and because the log is retained rather than consumed, producers and consumers are **decoupled in time** — a consumer can be down for an hour or added years later and still get the full story. You build services against it the way this @@ -400,7 +400,7 @@ consequences. A live consumer sits at the head, processing new records as they a An analytics job can reset to offset zero and replay the entire history. A brand-new consumer can join later and read all of history to build its state, with **no impact on the producer or on existing consumers** — nobody is draining a queue, so nobody -contends for the messages. Replay is simply moving an offset backward. That is the +contends for the messages. Replay is moving an offset backward. That is the foundation for event sourcing, for rebuilding a stream processor's state after a crash, and for the windowed computation taken up in **06 · Stream Processing**. Both Kafka (run by Strimzi in our cluster) and Pulsar give you this durable, replayable, @@ -428,7 +428,7 @@ per-tenant isolation and quotas — and **geo-replication** is built in. And one substrate covers both jobs: its **subscription modes** (exclusive, shared, failover, key-shared) yield a competing-consumer *queue* and a streaming *log* from the same topic. The price is operational surface — brokers plus BookKeeper plus a metadata -store — which earns its keep only when you actually need those properties. +store — which pays off only when you need those properties. ### AMQP / RabbitMQ — routing through exchanges @@ -451,12 +451,12 @@ consumers — and per-message **ack / nack / requeue** makes it excellent for ** distribution and request/reply**, where each task goes to exactly one worker and is retried on failure. The trade-offs are the flip side: there is **no replay** (a consumed message is gone unless you kept a copy), and throughput **degrades as queues -deepen**, because a backed-up queue is the broker holding the very state it was -designed to shed. Reach for it when the job is genuinely a task queue with complex +deepen**, because a backed-up queue is the broker holding the state it was +designed to shed. Reach for it when the job is a task queue with complex routing, not an event log you rewind. For an event backbone the system treats as its memory, the log model is the one that -wins — which is the idea the rest of this chapter builds on. +wins — which is the idea this chapter builds on. ## Building event-driven microservices: consume, process, produce @@ -663,7 +663,7 @@ what the next pattern depends on. Once data is liberated, events come in two styles. An **event notification** is thin — "order A-1001 was placed" — and tells a consumer only that something happened; to act on -it, the consumer must **call back** to the source for the details, which quietly +it, the consumer must **call back** to the source for the details, which re-introduces the synchronous coupling events were meant to remove. **Event-carried state transfer (ECST)** instead puts enough state *in the event* — the order's customer, total, and status — so the consumer needs no call-back at all. @@ -677,7 +677,7 @@ With ECST the consumer **folds** events into its own **local, denormalized mater view** — a read model shaped exactly for its queries, kept current by the stream and rebuildable by replay. Reads become local and fast, and a momentary outage of the source no longer stalls the consumer. The cost is **denormalization**: the same facts now live -in several services' views, kept eventually consistent by the stream — the deliberate +in several services' views, kept eventually consistent by the stream — the trade an event-driven system makes, spending storage and duplication to buy decoupling and autonomy. The consumer side is a fold into an upsert; the event alone is enough to build the row: @@ -745,8 +745,8 @@ There is no network round-trip to a shared database, so it is fast — but it is so it has to survive the instance dying. That is what the **changelog** is for. Every write to the state store is also appended to -a **compacted changelog topic** in the broker, which makes the store durable and, -crucially, **rebuildable**: when an instance crashes or a rebalance moves its partition +a **compacted changelog topic** in the broker, which makes the store durable and +**rebuildable**: when an instance crashes or a rebalance moves its partition to another instance, the new owner replays the changelog to restore the store before it resumes. State lives locally for speed and in the log for safety — the table–stream duality doing real work. @@ -758,7 +758,7 @@ duality doing real work. Maintaining a per-customer running total looks different in each ecosystem, because the embedded-store-plus-changelog machinery is exactly what the JVM stream-processing -frameworks give you for free and the others assemble: +frameworks provide and the others assemble: {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} @@ -831,7 +831,7 @@ func aggregate(ctx goka.Context, msg any) { } ``` -The honest ecosystem note: Kafka Streams (Spring, Quarkus), its .NET port Streamiz, and +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` 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, @@ -848,7 +848,7 @@ of arrival order: if its version is newer than what you hold, so a late `v1` can't clobber a `v2` already applied. - Prefer **commutative** updates — adding to a counter, unioning a set — where order - simply doesn't matter. + doesn't matter. - Make every handler **idempotent**, so a duplicate is a no-op (dedupe by event id, or fold into an upsert). @@ -922,7 +922,7 @@ if !ok || e.Version > cur.Version { // highest version wins ## Event time, processing time, and determinism A stream processor sees two clocks. **Processing time** is when the consumer happens to -handle an event; **event time** is when the thing actually happened, carried *in* the +handle an event; **event time** is when the thing happened, carried *in* the event. They drift apart — an event can be produced at 10:00, sit in a partition, and be processed at 10:05 — and they drift by different amounts after a backlog or a replay. The rule that keeps results stable is to compute on **event time, never the wall clock**. Do @@ -940,7 +940,7 @@ side output rather than silently corrupting a closed result. {% include excalidraw.html file="05-event-time-watermark" alt="Events arrive in processing-time order with event-times 12, then 10 (out of order), then 9 (late). A handler orders them by event time with a watermark at T equals 11. Windows up to 11 close into a deterministic result; the event with event-time 9, which is older than T, is late and goes to a side output. A note: key computation off event time, not the wall clock, so replaying the log yields the same answer." - caption="Figure 5.15 — Event time plus a watermark: close windows by event time and handle late events deliberately" %} + caption="Figure 5.15 — Event time plus a watermark: close windows by event time and handle late events explicitly" %} The deep mechanics of windows and watermarks — tumbling, sliding, and session windows, and how a stateful engine like Flink advances them — are the subject of **06 · Stream @@ -958,7 +958,7 @@ skipped and none is double-counted. {% include excalidraw.html file="05-checkpoint-recovery" - alt="A processor consumes from an input log, holding local state and an offset, and periodically snapshots state and offset together to a checkpoint store. On a crash, recovery restores from the checkpoint, seeks the input to the saved offset, and resumes the processor by replaying forward. A note: snapshot state and input offset together so on failure you restore and replay from there — and rewind on purpose to reprocess and rebuild." + alt="A processor consumes from an input log, holding local state and an offset, and periodically snapshots state and offset together to a checkpoint store. On a crash, recovery restores from the checkpoint, seeks the input to the saved offset, and resumes the processor by replaying forward. A note: snapshot state and input offset together so on failure you restore and replay from there — and rewind to reprocess and rebuild." caption="Figure 5.16 — A checkpoint snapshots state and offset as a unit, so recovery restores both and replays the gap" %} The last piece is making the *output* survive recovery too. Replaying after a crash @@ -1023,7 +1023,7 @@ cl.Produce(ctx, &kgo.Record{Topic: "order.validated", cl.EndTransaction(ctx, kgo.TryCommit) // commit output + offsets ``` -Reprocessing is the same machinery used on purpose. Because the log retains history and +Reprocessing is the same machinery, triggered by choice rather than by a crash. Because the log retains history and the processor keys off event time, you can **rewind the offsets** — to a checkpoint, or all the way to zero — and reprocess: to fix a bug in the logic, to rebuild a consumer's state from scratch, or to populate a brand-new view. Replay is not only a recovery path; @@ -1053,7 +1053,7 @@ the orchestrator is a **coupling point** and one more thing to keep available. caption="Figure 5.17 — Choreography reacts to events with no coordinator; orchestration centralizes the flow in one" %} Neither is universally right. Choreography suits simple, stable flows where decoupling -matters most; orchestration earns its keep when a process is complex, needs visibility, or +matters most; orchestration pays off when a process is complex, needs visibility, or changes often. Many systems mix them — choreography between bounded contexts, orchestration within one. @@ -1251,7 +1251,7 @@ Whichever you use, put the **tenant id in the partition key** so a tenant's even their order and land together, never derive a topic name from unsanitized tenant input, and choose between a **shared topic keyed by tenant** (simple, weaker isolation) and a **topic or namespace per tenant** (stronger isolation, more sprawl to manage) based on how hard the -isolation requirement really is. +isolation requirement is. ## Living with eventual consistency @@ -1265,7 +1265,7 @@ truth. That window is not a bug to hide; it is a property to design for. alt="A client writes to the source of truth, which commits and emits an event; the event is applied to a read model or view after a delay of delta-t. A read of the view during that delta-t window may be stale. A note: read-your-writes means routing a writer's own reads to the source, or waiting until the read model reaches the write's version." caption="Figure 5.20 — The consistency window: a read model lags the source by Δt, which read-your-writes hides for the writer" %} -The honest move is to decide where strong consistency is genuinely required — usually a +Decide where strong consistency is required — usually a small core, like the payment charge itself — and let everything else converge, with a few standard tactics to keep it humane: @@ -1279,7 +1279,7 @@ standard tactics to keep it humane: The throughline of the whole chapter: an event-driven system trades immediate consistency and a single in-the-moment truth for decoupling, autonomy, scale, and a complete, replayable -history. Spend that trade where it pays, and be deliberate about the few places it doesn't. +history. Spend that trade where it pays, and avoid it in the few places it doesn't. ### Cross-check it yourself diff --git a/_docs/06-stream-processing.md b/_docs/06-stream-processing.md index c8e67ecf..7b4e8082 100644 --- a/_docs/06-stream-processing.md +++ b/_docs/06-stream-processing.md @@ -190,8 +190,8 @@ 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 the -changelog-backed store for free — kill the pod and the window state rebuilds by +a derived `revenue.by-merchant` stream. The JVM, .NET, Python and Go versions (Kafka Streams, Streamiz, Faust, 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 worker, that is the signal to move to a dedicated engine like Flink rather than @@ -242,7 +242,7 @@ a different access pattern: {% include excalidraw.html file="06-window-types" - alt="Three rows of window types. Tumbling: fixed 5-minute buckets with no overlap — event lands in exactly one window. Hopping (sliding): 5-minute windows that advance every 1 minute, overlapping — event lands in multiple windows, giving a smoothed view. Session: variable-length windows defined by an inactivity gap — no events for 10 minutes closes the window, suited to user-activity sessions." + alt="Three rows of window types. Tumbling: fixed 5-minute buckets with no overlap, so an event lands in exactly one window. Hopping (sliding): 5-minute windows that advance every 1 minute, overlapping, so an event lands in multiple windows, giving a smoothed view. Session: variable-length windows defined by an inactivity gap; no events for 10 minutes closes the window, suited to user-activity sessions." caption="Figure 6.3 — Three window types: tumbling (fixed, no overlap), hopping (fixed, overlapping), and session (variable, gap-based)" %} **Tumbling windows** — fixed-size, non-overlapping. Every event lands in exactly one @@ -281,7 +281,7 @@ and watermarks at a scale that in-process libraries don't attempt. A stream processor's load is *backlog*, not CPU. The right scaling signal is **consumer lag** — how far behind the log the workers are. KEDA reads that lag and -scales workers up, and crucially **down to zero** when there is no lag. For +scales workers up, and **down to zero** when there is no lag. For synchronous HTTP services the same KEDA closes the loop on request rate instead. One mechanism, two signals. diff --git a/_docs/07-workflows-and-jobs.md b/_docs/07-workflows-and-jobs.md index 988c8961..5e3a2499 100644 --- a/_docs/07-workflows-and-jobs.md +++ b/_docs/07-workflows-and-jobs.md @@ -17,7 +17,7 @@ There are two ways to coordinate a multi-step process, and they are mirror image **Orchestration** puts a central coordinator in charge: it commands each service in turn — reserve stock, take payment, book shipping — and owns the end-to-end -flow. It is easy to follow, easy to observe, and easy to audit. Its failure mode +flow. It is straightforward to follow, observe, and audit. Its failure mode is the **god-service**: the coordinator accretes business logic until it becomes the monolith you were trying to avoid. @@ -51,7 +51,7 @@ The heuristic: than visibility — send a welcome email when a user signs up. Adding a reaction shouldn't require touching a coordinator. -The trap is using choreography for a process that's actually complex, then +The trap is using choreography for a process that's complex, then discovering six months later that no single place describes what the process *is*. When the flow has compensations and a lifecycle, that lifecycle wants to live somewhere explicit — which is exactly the persisted saga state machine in **Appendix D · Sagas**. @@ -95,7 +95,7 @@ spec: args: ["reconcile", "--since=24h"] ``` -A queue worker isn't a Job at all — it's an ordinary Deployment that loops on a +A queue worker is not a Job; it is an ordinary Deployment that loops on a topic, with the `ScaledObject` from the stream-processing chapter giving it scale-to-zero. The shape of the work picks the home: run-once is a Job, scheduled is a CronJob, continuous is a scaled Deployment. diff --git a/_docs/08-api-management.md b/_docs/08-api-management.md index 79c2c39a..1cdd1ad2 100644 --- a/_docs/08-api-management.md +++ b/_docs/08-api-management.md @@ -17,7 +17,7 @@ monolith. {% include excalidraw.html file="08-four-planes" alt="Four planes side by side: the runtime plane (gateway and mesh), the contract plane (registry, section 9), the discovery plane (metadata, section 10), and the signal plane (observability, section 11)" - caption="Figure 8.1 — Four planes, four owners — each a section of the platform" %} + caption="Figure 8.1 — Four planes, each a section of the platform with its own owner" %} - **Runtime plane** — the gateway and the Istio mesh. Traffic, authentication, rate limiting, and resilience happen here, on the wire, as configuration. This @@ -27,12 +27,12 @@ monolith. and event schema, versioned and gated in CI. - **Discovery plane** — the catalog and metadata (**10 · API Metadata**). How a human finds an API and decides whether to trust it. -- **Signal plane** — observability (**11 · Observability**). What is actually +- **Signal plane** — observability (**11 · Observability**). What is happening to the APIs in production. The point is the separation. A single "API gateway product" that tries to own all four becomes exactly the god-service the workflows chapter warned about — runtime -policy, contract storage, discovery UX, and telemetry pipelines have genuinely +policy, contract storage, discovery UX, and telemetry pipelines have different owners and release cadences, and fusing them into one box recreates the monolith at the edge. Distinct planes have distinct owners and evolve independently; this chapter's job is largely to insist they stay distinct. @@ -101,13 +101,13 @@ policy, not code; changing the rate or the burst is a config commit, not a redep Versioning is a management concern, not an afterthought bolted on when something breaks. The core discipline is to **evolve additively**: add optional fields and new -endpoints; never repurpose a field's meaning and never silently remove one. This +endpoints; never repurpose a field's meaning and never remove one without warning. This works because well-behaved consumers ignore fields they do not recognise, so a new optional field is invisible to old clients while available to new ones. A consumer written against last year's contract should still work against this year's service without a single change. -When you genuinely must break compatibility, you don't mutate the existing +When you must break compatibility, you don't mutate the existing contract — you stand up a new major version beside the old one. Carry the major in the path (`/v2`) or in the media type, run both side by side, publish a **sunset date**, and migrate consumers off before retiring the old one. The mechanics — diff --git a/_docs/09-api-registry.md b/_docs/09-api-registry.md index 854f0b18..89bc14cf 100644 --- a/_docs/09-api-registry.md +++ b/_docs/09-api-registry.md @@ -324,7 +324,7 @@ func isCompatible(group, artifactID string, schema []byte) (bool, error) { ### Cross-check it yourself -Prove the gate bites. Register `order-placed.avsc`, then make a genuinely breaking +Prove the gate bites. Register `order-placed.avsc`, then make a breaking change — rename a required field — and `curl` the new version: you should get a `400`, and the CI step should exit non-zero. Then make an additive change — a new *optional* field — and confirm it returns `200` and publishes a new version. The diff --git a/_docs/10-api-metadata.md b/_docs/10-api-metadata.md index 9b7eb440..df738624 100644 --- a/_docs/10-api-metadata.md +++ b/_docs/10-api-metadata.md @@ -8,7 +8,7 @@ duration: 14 minutes A contract that is enforced but undiscoverable is half useless. If a consumer can't find your API and decide whether to trust it, they'll build their own or -screen-scrape yours — and the boundary you worked to establish quietly erodes. +screen-scrape yours — and the boundary you worked to establish erodes. Metadata is the **discovery and trust plane**: a searchable catalog of every API, topic, and table, with ownership, lineage, and quality signals attached. @@ -218,7 +218,7 @@ The throughline of the last two chapters: a contract is only as good as your ability to **find it, trust it, and know who owns it.** The registry makes the contract authoritative; the catalog makes it discoverable and accountable. Together they turn "we have contracts somewhere" into a platform people can -actually build on. +build on. ### Cross-check it yourself diff --git a/_docs/11-observability.md b/_docs/11-observability.md index 84b0505b..648aa7ff 100644 --- a/_docs/11-observability.md +++ b/_docs/11-observability.md @@ -7,7 +7,7 @@ duration: 28 minutes --- The signal plane. Once you have services, events, and a platform, the question is -what is *actually* happening in production — and the answer is three signals, +what is happening in production — and the answer is three signals, emitted by **OpenTelemetry** (vendor-neutral) and stored in an LGTM-style stack (Loki, Grafana, Tempo, Mimir). @@ -197,14 +197,14 @@ func placeOrder(w http.ResponseWriter, r *http.Request) { Every tab does two things: turn on auto-instrumentation for the frameworks (one line of config, or a builder chain in .NET), and add **one** manual span around the business operation — `reserve_stock`. The plumbing spans (HTTP in, gRPC out, Kafka -publish) come for free; the domain span is the bit only you can write. The honest +publish) need no code; the domain span is the bit only you can write. The one exception is C++: there is no auto-instrumentation for Drogon or `librdkafka`, so you add a request-scoped middleware span by hand — the chapter says so rather than pretending the agent exists. ## The other two signals, in code -Auto-instrumentation gives you trace spans for free, but the **metric** and the +Auto-instrumentation creates the trace spans, but the **metric** and the **trace-correlated log** are a line you write at the business moment. Emit a counter (or histogram) for the operation, and log through the OTel-aware logger so the active `trace_id` is stamped onto the line — that stamp is what lets you jump from a log @@ -303,7 +303,7 @@ The metric feeds the RED/USE dashboards and SLO alerts; the log carries the `trace_id` so Loki can filter to exactly the request you are chasing. Same operation, three correlated signals from one place in the code. -## How correlation actually works +## How correlation works "Correlation" sounds like backend magic; it isn't. The trace context travels **with the request, in band, at every hop** — the W3C `traceparent` header, @@ -429,7 +429,7 @@ processors: ``` The shape — keep errors, keep slow, sample the rest — is the one most teams want: -you never drop the traces you'd actually investigate, and you pay full storage only +you never drop the traces you'd investigate, and you pay full storage only for the boring majority you sample down. ### Cross-check it yourself diff --git a/_docs/12-security.md b/_docs/12-security.md index 9869a017..01e84c08 100644 --- a/_docs/12-security.md +++ b/_docs/12-security.md @@ -298,7 +298,7 @@ inside the cluster, must carry a verifiable identity and pass policy on *every* {% include excalidraw.html file="12-zero-trust" - alt="Top: the old perimeter model — VPN to a trusted intranet to any service — where getting in once means trusted forever and lateral movement is trivial. Bottom: the zero-trust model — user/device to an IAP/auth proxy (who, where, device, MFA, posture) to a policy engine making a per-request decision (OPA/Cedar) to a service that accepts only requests carrying a valid claim, repeated for every downstream call, with mTLS and SPIFFE identity inside the cluster." + alt="Top: the old perimeter model (VPN to a trusted intranet to any service), where getting in once means trusted forever and lateral movement is trivial. Bottom: the zero-trust model: user/device to an IAP/auth proxy (who, where, device, MFA, posture) to a policy engine making a per-request decision (OPA/Cedar) to a service that accepts only requests carrying a valid claim, repeated for every downstream call, with mTLS and SPIFFE identity inside the cluster." caption="Figure 12.4 — Perimeter trust versus zero-trust: identity and policy are checked on every hop, not once at the edge" %} In our stack this is layered: an identity-aware proxy at the edge checks who and what diff --git a/_docs/13-anti-patterns.md b/_docs/13-anti-patterns.md index 7ed06d8c..b51f9fc0 100644 --- a/_docs/13-anti-patterns.md +++ b/_docs/13-anti-patterns.md @@ -9,7 +9,7 @@ duration: 16 minutes Here is the most useful framing in the book, and the one to remember for code review: **nearly every cloud-native failure is coupling sneaking back in.** You did the work to separate things into services with contracts and events. Entropy and -deadlines pull them quietly back together. This closing chapter names the specific +deadlines pull them back together. This closing chapter names the specific ways that happens and gives you heuristics to catch them. ## How cloud-native API systems go wrong @@ -19,7 +19,7 @@ exactly the coupling the architecture worked to remove: {% include excalidraw.html file="13-antipatterns-grid" - alt="A grid of nine anti-patterns, each re-coupling what the architecture worked to separate: distributed monolith (services deploy together), shared database (two services one schema), chatty N+1 APIs (a screen makes 40 calls), sync call chains (A to B to C to D, availability multiplies down), no contract or registry (breaking changes ship silently), smart pipes and dumb endpoints (business logic creeps into the gateway), missing observability (debugging by guesswork), retry storms with no backoff (retries amplify an incident into an outage), and leaky internal models (DB rows exposed as the public API)." + alt="A grid of nine anti-patterns, each re-coupling what the architecture worked to separate: distributed monolith (services deploy together), shared database (two services one schema), chatty N+1 APIs (a screen makes 40 calls), sync call chains (A to B to C to D, availability multiplies down), no contract or registry (breaking changes go unflagged), smart pipes and dumb endpoints (business logic creeps into the gateway), missing observability (debugging by guesswork), retry storms with no backoff (retries amplify an incident into an outage), and leaky internal models (DB rows exposed as the public API)." caption="Figure 13.1 — The recurring anti-patterns; each one re-couples what the platform worked to separate" %} - **Distributed monolith** — services that must be deployed together. You paid all @@ -75,7 +75,7 @@ If you remember nothing else: ## The shoulders this stands on -The slides and these references are enough to actually build the system, not just +The slides and these references are enough to build the system, not just talk about it: - Sam Newman, *Building Microservices* (2nd ed.) — service boundaries and diff --git a/_docs/14-appendix-a-protocols.md b/_docs/14-appendix-a-protocols.md index 6467a071..6b14296c 100644 --- a/_docs/14-appendix-a-protocols.md +++ b/_docs/14-appendix-a-protocols.md @@ -4,16 +4,16 @@ marker: "A" label: "Appendix A" order: 14 part: "Deep-dive appendices" -description: "The deep dive behind 'pick the protocol' — REST, gRPC, and GraphQL compared on three axes: the mental model each imposes, how each handles fetching and round trips, and what actually travels on the wire." +description: "The deep dive behind 'pick the protocol' — REST, gRPC, and GraphQL compared on three axes: the mental model each imposes, how each handles fetching and round trips, and what travels on the wire." duration: 26 minutes --- This is the deep dive behind the protocol choice in **Communications**. The same `place_order` intent takes three shapes, and the way to choose between them is to compare the three on three axes: the **mental model** each imposes, how each -handles **fetching and round trips**, and what actually **travels on the wire**. +handles **fetching and round trips**, and what **travels on the wire**. -## Three paradigms, three mental models +## Three paradigms and their mental models Start with the mental model, because it drives everything else: @@ -26,7 +26,7 @@ Start with the mental model, because it drives everything else: {% include excalidraw.html file="14-three-paradigms" - alt="Three columns for the same place_order intent. REST as resources: nouns plus HTTP verbs, POST to orders, GET an order by id, state transfer, server defines payload — resource-oriented. gRPC as procedures: typed method calls like Order.Place and Inventory.Reserve, behaviour not state, contract is the proto — RPC-oriented. GraphQL as a graph: one typed schema, the client picks fields and traverses relationships, contract is the SDL — query-oriented." + alt="Three columns for the same place_order intent. REST as resources: nouns plus HTTP verbs, POST to orders, GET an order by id, state transfer, server defines payload (resource-oriented). gRPC as procedures: typed method calls like Order.Place and Inventory.Reserve, behaviour not state, contract is the proto (RPC-oriented). GraphQL as a graph: one typed schema, the client picks fields and traverses relationships, contract is the SDL (query-oriented)." caption="Figure A.1 — One intent, three mental models: resources, procedures, and a typed graph" %} ## The three axes, side by side @@ -50,7 +50,7 @@ typed binary call. The third axis is what literally travels between processes, and it is the one that decides reach and caching. REST rides ordinary HTTP, so browsers and shared caches -handle it for free; gRPC trades that reach for HTTP/2 streaming and a compact binary +handle it natively; gRPC trades that reach for HTTP/2 streaming and a compact binary encoding; GraphQL keeps HTTP reach but moves caching up into the application. {% include excalidraw.html @@ -259,15 +259,13 @@ markedly cheaper than JSON over HTTP/1.1. | **Persistent channels** | one long-lived (TLS) connection, reused | no per-call handshake cost | | **Generated stubs** | (de)serialisation is compiled, not reflective | less CPU per call than runtime JSON mapping | -Two honest caveats. The win is largest exactly where you'd expect it — chatty, +Two caveats. The win is largest where you'd expect it — chatty, internal, east-west paths — and shrinks for occasional, large-payload calls where the body dominates. And none of it reaches a browser directly: a browser needs `grpc-web` and a proxy, which is why the public edge usually stays REST or GraphQL. ## Choosing between them in practice -Said plainly: - - **Public, partner-facing, browser or third-party clients, caching matters → REST.** - **Internal service-to-service, low latency, high call volume, streaming → gRPC.** @@ -278,8 +276,8 @@ Said plainly: The closing point ties back to the whole book: forcing one protocol everywhere — "we're a gRPC shop," "everything is GraphQL" — is *itself* an anti-pattern. Each -protocol earns its place on a specific axis; a healthy system uses all of them -where they fit. +protocol is strongest on a specific axis; a healthy system uses each where it +fits. ## Common mistakes with each diff --git a/_docs/15-appendix-b-versioning.md b/_docs/15-appendix-b-versioning.md index 90865cae..6cd57293 100644 --- a/_docs/15-appendix-b-versioning.md +++ b/_docs/15-appendix-b-versioning.md @@ -17,7 +17,7 @@ rhyme, so we end with those. Ordered coarse to fine: - **URI path** (`/v2/orders`) — visible and trivial to route, but couples the - version to the URL and arguably breaks REST's idea that a URL identifies a + version to the URL and breaks REST's idea that a URL identifies a *resource*, not a representation. Common precisely because it's obvious. - **Query parameter** (`?version=2`) — easy to default, but muddies caches (the same URL now returns different shapes). @@ -43,7 +43,7 @@ Content-Type: application/vnd.acme.order.v2+json ## Which REST scheme to start with — and what it costs HATEOAS and security For a new public or partner API, **start with URI-path versioning** (`/v1/orders`). -It is the least elegant of the four but the most operable: trivially routable, +It is the least elegant of the four but the most operable: routable, visible in logs and dashboards, cacheable without surprises, and impossible for a client to get wrong. Reach for **media-type versioning** when you specifically need the URL to stay stable — which, as it happens, is exactly what HATEOAS needs. @@ -58,7 +58,7 @@ travels with those links: - **URI-path** works too, but every emitted link must already carry the version (`/v2/orders/A-1001/items`); a client that follows links stays pinned to one version, which is fine until you want it to move. -- **Query-param** and **custom-header** versioning quietly break link-following: the +- **Query-param** and **custom-header** versioning break link-following without any error: the version lives outside the URL, so a naively followed link drops it. **Impact on security.** Each live version is attack surface you must keep patched — @@ -230,7 +230,7 @@ thing to separate is the two roles a Kafka message plays, which are easy to conf The default that keeps consumers working is **one topic with a BACKWARD-compatible, evolving schema**: add fields, never renumber or repurpose them, and old consumers -ignore what they don't know while new consumers read the additions. Only a genuine +ignore what they don't know while new consumers read the additions. Only a breaking change earns a new major — and then you run two topics (or two schema majors) side by side, dual-write through the migration, and retire the old once consumers have moved. @@ -317,7 +317,7 @@ The protocol-specific rules are all faces of four ideas: *add*. - **Make compatibility mechanical** — the registry's `BACKWARD`/`FORWARD`/`FULL` rules checked in CI, never a reviewer remembering. -- **Never break silently** — deprecate with a reason, a replacement, and a sunset +- **Never break without notice** — deprecate with a reason, a replacement, and a sunset date, and measure who's still on the old version. - **Run old and new side by side** until consumers have migrated, then retire. diff --git a/_docs/16-appendix-c-websockets.md b/_docs/16-appendix-c-websockets.md index a86c3c74..95caa413 100644 --- a/_docs/16-appendix-c-websockets.md +++ b/_docs/16-appendix-c-websockets.md @@ -8,8 +8,8 @@ description: "The protocol we left out of the main interaction styles, because i duration: 28 minutes --- -WebSockets were deliberately left out of the main interaction-styles discussion -because they behave so differently on Kubernetes. A WebSocket is long-lived, +WebSockets get their own appendix instead of a place in the main interaction-styles +discussion because they behave so differently on Kubernetes. A WebSocket is long-lived, stateful, and **pinned to a pod** — the opposite of the disposable, stateless request the platform is built around. @@ -162,7 +162,7 @@ pod that doesn't happen to hold X's socket. The fix keeps the part that's fine — each pod still owns only its own sockets — and adds a shared **backplane** that every pod subscribes and publishes to: **Redis pub/sub** for low latency, or a **Kafka topic** when you want durability and replay. -To send to user X, any pod publishes to the backplane; the pod that actually holds +To send to user X, any pod publishes to the backplane; the pod that holds X's socket is subscribed, receives it, and writes it down the wire. No pod needs to know where any client lives. @@ -436,7 +436,7 @@ the missed range. The user sees a brief reconnect, not a lost stream. caption="Figure C.5 — Failover without interruption: reconnect lands on a live pod, which replays the gap from the backplane" %} The one requirement is that the missed range be *reconstructable* from somewhere the -new pod can reach. A Kafka backplane with retention gives this for free — the new pod +new pod can reach. A Kafka backplane with retention provides this directly — the new pod seeks to `seq + 1` and replays. With Redis pub/sub, which has no history, pair it with a short server-side ring buffer (or a Redis stream) keyed by sequence so the gap can be re-sent. Either way the contract is at-least-once, deduplicated by `seq`. @@ -456,7 +456,7 @@ so performance work is mostly about keeping the *per-message* cost low and the | **Tuned heartbeats** | ping often enough to detect a dead link in seconds, rarely enough not to wake every idle connection constantly | | **Per-pod connection caps** | memory-per-connection × cap sizes the pod; scale and trigger on connection count, not CPU | -The throughline with the rest of this appendix: most of these are the same levers +The throughline of the WebSocket patterns: most of these are the same levers that make the system *scale* — bounded buffers and connection caps are what let the HPA reason about a pod, and binary framing is what keeps the backplane cheap. @@ -467,7 +467,7 @@ like. The idea is simple: each connection gets a fixed-size outbound queue. When queue is full — because the client is reading slower than you are writing — you either **drop the message** (acceptable for live-updating dashboards where the next update replaces the stale one) or **disconnect the client** (preferable when every message -must be delivered and the client is genuinely stuck): +must be delivered and the client is stuck): ```csharp // bounded send buffer — drop or disconnect when the client can't keep up @@ -502,7 +502,7 @@ unbounded buffer until the pod OOMs. Everything in this appendix uses raw WebSockets — direct `ws.SendAsync` or `c.Write`, manual heartbeats, hand-built backplanes, explicit sequence tracking. -This is deliberate: raw sockets are cross-language, and the patterns (backplane, +The appendix stays on raw sockets because they are cross-language, and the patterns (backplane, resume, binary framing) are universal. **SignalR** is ASP.NET Core's real-time abstraction that sits *on top of* WebSockets @@ -549,7 +549,7 @@ SignalR**. ### Cross-check it yourself -Make the backplane earn its place. Connect a client to `ws-pod-1`, then send it a +Prove the backplane carries messages between pods. Connect a client to `ws-pod-1`, then send it a message *from* `ws-pod-2`: with the backplane wired up it arrives; rip the backplane out and it vanishes, because no other pod can reach that socket. Then scale the Deployment down by one and confirm the dropped clients reconnect with backoff and diff --git a/_docs/17-appendix-d-sagas.md b/_docs/17-appendix-d-sagas.md index 6f457bc0..0a52b515 100644 --- a/_docs/17-appendix-d-sagas.md +++ b/_docs/17-appendix-d-sagas.md @@ -19,13 +19,13 @@ 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. The workhorse option is a **DB-backed state machine**: one row per saga instance holding its `status`, a `step_index`, and a `context` blob, with *you* -owning the transitions. Crucially, the state update and the next command commit +owning the transitions. The state update and the next command commit **together** in one transaction — the same outbox discipline from the Data chapter, applied to the saga's own progress. {% include excalidraw.html file="17-saga-state" - alt="Four ways a saga can keep its state. In-memory: a dict or object only, lost on pod restart, never for real sagas. DB state machine: one row per saga instance with status and context JSON, and you own the transitions. Event-sourced: append step events and rebuild by replay, with full audit history. Workflow engine: Temporal or Camunda, durable execution with retries and timers built in. Whatever the store, the state transition and the outbound command must commit together." + alt="Four ways a saga can keep its state. In-memory: a dict or object only, lost on pod restart, not for production. DB state machine: one row per saga instance with status and context JSON, and you own the transitions. Event-sourced: append step events and rebuild by replay, with full audit history. Workflow engine: Temporal or Camunda, durable execution with retries and timers built in. Whatever the store, the state transition and the outbound command must commit together." caption="Figure D.1 — Where saga state can live; the DB-backed state machine is the workhorse, but the commit-together rule is the same for all" %} {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} @@ -149,7 +149,7 @@ picks up exactly where it left off. There is no cross-service rollback — when `book_shipping` fails, the `charge_payment` that already committed in the payment service is *real money*. So compensation runs a defined **semantic inverse** for each step that completed — `charge → refund`, -`reserve → release` — **in reverse order**, and only for the steps that actually +`reserve → release` — **in reverse order**, and only for the steps that finished. {% include excalidraw.html @@ -304,8 +304,8 @@ confirm the database shows `release_stock` then `refund_payment` executing in th order — and that `cancel_shipping` does *not* run, because shipping never completed. Then kill the orchestrator pod mid-flow and restart it: the `RUNNING` saga should resume from its persisted `step_index` and `context`, not start over. Reverse-order -inverses over only the completed steps, plus a clean resume, is the saga actually -working. +inverses over only the completed steps, plus a clean resume, shows the saga +works. --- *Verification status: verified — [`examples/17-sagas/`](https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/17-sagas/) passes 11/11 checks in each of the six languages on Docker Engine (2026-10-10) diff --git a/_docs/18-appendix-e-errors.md b/_docs/18-appendix-e-errors.md index 48bffdb6..bdef9cfa 100644 --- a/_docs/18-appendix-e-errors.md +++ b/_docs/18-appendix-e-errors.md @@ -10,8 +10,8 @@ duration: 24 minutes Cloud-native systems rarely fall apart on the happy path; they fall apart on the error path, because that path gets the least design attention and the least -testing. This appendix fixes the error path the same way the rest of the book -fixes everything else: **agree one contract, then encode it consistently.** Pick a +testing. This appendix fixes the error path with the principle the book applies +throughout: **agree one contract, then encode it consistently.** Pick a single error model — a stable machine code, a safe human message, a correlation id, a retryable flag, and structured field details — and map *that same model* onto each protocol the system speaks: REST, gRPC, Kafka, and GraphQL. We close on the failure @@ -59,8 +59,8 @@ The **HTTP status class is the category**, and it is what drives client behaviou The discipline that makes this maintainable is **one mapping seam**: domain code raises a typed exception, and exactly one place turns it into the wire format, so -ad-hoc error JSON never spreads across endpoints. Notice what every version here -deliberately omits — no stack trace, no exception class name, no SQL. The status +ad-hoc error JSON never spreads across endpoints. Every version here +omits internals — no stack trace, no exception class name, no SQL — so nothing about the implementation leaks. The status matches the category (`409`, a conflict the client can act on), and the body carries the code and the trace id for correlation. diff --git a/_docs/19-appendix-f-ddd-hexagonal.md b/_docs/19-appendix-f-ddd-hexagonal.md index 402c26cd..6d03d993 100644 --- a/_docs/19-appendix-f-ddd-hexagonal.md +++ b/_docs/19-appendix-f-ddd-hexagonal.md @@ -8,7 +8,7 @@ description: "Where service boundaries and API contracts come from — strategic duration: 22 minutes --- -The rest of this book assumes the boundaries are already drawn: that each service +The other chapters assume the boundaries are already drawn: that each service owns a coherent slice of the business, and that its API says the right things. This appendix is where those assumptions come from. Domain-Driven Design supplies the *strategic* tools to place a boundary and the *tactical* tools to model what lives @@ -35,7 +35,7 @@ dominate outcomes far more than protocol polish does. Classify the business by **value and rate of change**. A *core* subdomain is the competitive edge — complex, volatile, where the strongest people belong. A -*supporting* subdomain is necessary but not differentiating, so build it simply. A +*supporting* subdomain is necessary but not differentiating, so keep its design simple. A *generic* subdomain is a solved problem you adopt or buy rather than invent — auth, notifications. In the running system, order and pricing are core; inventory and shipping are supporting; auth and notification are generic. The classification is not @@ -57,13 +57,13 @@ context end to end. {% include excalidraw.html file="19-strategic-ddd" - alt="Strategic DDD classifies the business into three kinds of subdomain by value and rate of change. Core: your competitive edge, complex and volatile, your best people — order and pricing. Supporting: necessary but not a differentiator, built simply — inventory and shipping. Generic: a solved problem you buy or adopt off the shelf — auth and notification. Each subdomain becomes one or more bounded contexts, a model boundary with its own ubiquitous language, and the API is that boundary: one context's order is not another's, and the contract translates between models rather than sharing internals." + alt="Strategic DDD classifies the business into three kinds of subdomain by value and rate of change. Core: your competitive edge, complex and volatile, your best people (order and pricing). Supporting: necessary but not a differentiator, kept simple (inventory and shipping). Generic: a solved problem you buy or adopt off the shelf (auth and notification). Each subdomain becomes one or more bounded contexts, a model boundary with its own ubiquitous language, and the API is that boundary: one context's order is not another's, and the contract translates between models rather than sharing internals." caption="Figure F.1 — Subdomains classified by value and volatility; each becomes a bounded context whose API is the boundary" %} ## Context mapping — how contexts integrate Context mapping catalogues the ways two contexts relate, and three of those patterns -are the ones an API actually implements: +are the ones an API implements: - An **Open-Host Service** is a context that, because many others integrate with it, publishes a stable, documented API as its public protocol — that is your REST, @@ -80,7 +80,7 @@ upstream is clean and stable, and the wrong one when it is messy or volatile — an ACL localises the blast radius of their changes to a single translation layer. The remaining map entries are trade-offs you choose rarely: a Shared Kernel means two contexts share a model and therefore change in lockstep (high coupling, use sparingly); -a Partnership means they co-evolve deliberately; Separate Ways means do not integrate +a Partnership means they co-evolve in coordination; Separate Ways means do not integrate at all. The payoff of this section is recognition: the registry-backed contract you already have is a Published Language, and the gateway or consumer-edge translation you already do is an Anticorruption Layer — now they have names and a theory behind them. @@ -88,7 +88,7 @@ already do is an Anticorruption Layer — now they have names and a theory behin {% include excalidraw.html file="19-context-mapping" alt="The eight context-mapping patterns for how two bounded contexts integrate. The three API-relevant ones are starred: Open-Host Service, publish a stable API for all comers; Published Language, a shared schema in OpenAPI, proto, or Avro; and Anticorruption Layer, translate a foreign model at your edge. The others are Conformist, accept the upstream model as-is; Customer and Supplier, upstream serves a negotiated downstream; Shared Kernel, share a model with high coupling used sparingly; Partnership, two contexts evolve together; and Separate Ways, don't integrate at all." - caption="Figure F.2 — The context-mapping patterns; the starred three are the ones an API actually implements" %} + caption="Figure F.2 — The context-mapping patterns; the starred three are the ones an API implements" %} ## Hexagonal architecture — protocols are adapters @@ -104,7 +104,7 @@ points inward: adapters depend on the domain, never the reverse. {% include excalidraw.html file="19-hexagonal-ports" - alt="A central domain core that depends on nothing, declaring ports. On the left, three driving adapters — REST, gRPC, GraphQL — call inward through an inbound port. On the right, two driven adapters — a Postgres repository and a Kafka publisher — implement outbound ports. All dependency arrows point inward toward the core." + alt="A central domain core that depends on nothing, declaring ports. On the left, three driving adapters (REST, gRPC, GraphQL) call inward through an inbound port. On the right, two driven adapters (a Postgres repository and a Kafka publisher) implement outbound ports. All dependency arrows point inward toward the core." caption="Figure F.3 — The domain core is isolated; the four protocols are interchangeable adapters around it" %} The consequence is the one this book cares about: the four protocols are @@ -361,7 +361,7 @@ domain types and the ports it declares. Then prove replaceability by constructio a second driving adapter (a gRPC endpoint, or a Kafka consumer) that calls the *same* `PlaceOrder` use case, and confirm the domain files do not change in that diff — only a new adapter file appears. A clean domain import list plus a no-domain-change diff when -you bolt on a protocol is hexagonal architecture actually holding. +you bolt on a protocol is hexagonal architecture holding. --- *Verification status: verified — [`examples/19-ddd-hexagonal/`](https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/19-ddd-hexagonal/) passes 17/17 checks in each of the six languages on Docker Engine (2026-10-10) diff --git a/_docs/20-appendix-g-coupling.md b/_docs/20-appendix-g-coupling.md index 79ecb3c4..d373f19f 100644 --- a/_docs/20-appendix-g-coupling.md +++ b/_docs/20-appendix-g-coupling.md @@ -38,7 +38,7 @@ only want to pay it where the coupling across that distance is weak. 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 any change that has to cross the boundary. **Volatility** is how often the coupled -thing actually changes; it is the dimension you cannot observe directly, but the DDD +thing changes; it is the dimension you cannot observe directly, but the DDD appendix already gave the tool to predict it — a subdomain's type tells you its change-rate, with core subdomains volatile and generic ones stable. @@ -79,7 +79,7 @@ across that distance is how the distributed monolith forms. {% include excalidraw.html file="20-strength-ladder" - alt="Integration strength as a four-rung ladder, worst to best, each with its example in our system. Intrusive coupling reaches into another's private internals — a shared database or scraping internals. Functional coupling shares business logic and rules — duplicated or co-owned domain logic. Model coupling shares a data model or DTOs — a shared library of types. Contract coupling integrates via an explicit, stable contract — REST, gRPC, or GraphQL plus a registry schema. Across services, aim for contract coupling." + alt="Integration strength as a four-rung ladder, worst to best, each with its example in our system. Intrusive coupling reaches into another's private internals, such as a shared database or scraping internals. Functional coupling shares business logic and rules, such as duplicated or co-owned domain logic. Model coupling shares a data model or DTOs, such as a shared library of types. Contract coupling integrates via an explicit, stable contract: REST, gRPC, or GraphQL plus a registry schema. Across services, aim for contract coupling." caption="Figure G.2 — The integration-strength ladder; across services, aim for the bottom rung — contract coupling" %} ## Strength × distance, and volatility as the trigger @@ -90,11 +90,11 @@ quadrants — only one of which is dangerous. {% include excalidraw.html file="20-coupling-quadrant" alt="A two-by-two of integration strength (vertical) against distance (horizontal). Strong and close is a cohesive module (healthy); strong and distant is the distributed monolith (the trap); weak and close is needless indirection; weak and distant is healthy microservices. A note states that volatility is the trigger — even the trap quadrant is harmless on code that never changes." - caption="Figure G.3 — Balance means having strength or distance, not both; volatility decides whether an imbalance actually bites" %} + caption="Figure G.3 — Balance means having strength or distance, not both; volatility decides whether an imbalance bites" %} The two healthy quadrants are a cohesive module (strong, close) and contract-integrated services (weak, distant). The bottom-left, weak-and-close, is merely needless -indirection — you have split things that could simply be merged. The top-right, +indirection — you have split things that could be merged. The top-right, strong-and-distant, is the distributed monolith, where one change ripples across services and teams. @@ -140,7 +140,7 @@ renumber a field. alt="Connascence in two families, each weaker at the top and stronger at the bottom. Static connascence, visible in the code: Name (agree on an identifier), Type (agree on a type or shape), Meaning (a shared convention or magic value), Position (the same order of arguments or fields), Algorithm (the same algorithm on both sides). Dynamic connascence, only at runtime: Execution order (A must run before B), Timing (race-sensitive), Value (values must stay in agreement), Identity (the same instance or entity). The rule: the stronger the connascence between two parts, the more local they must stay." caption="Figure G.4 — Connascence: static and dynamic forms, weakest to strongest — keep stronger forms more local" %} -The **afferent / efferent** entry is the one you can actually measure. Afferent +The **afferent / efferent** entry is the one you can measure. Afferent coupling (Ca) counts who depends on a module; efferent (Ce) counts what it depends on. Their ratio gives an *instability* score, `I = Ce / (Ca + Ce)`, running from 0 (nothing depends outward — maximally stable) to 1 (depends on everything, depended on by nothing @@ -158,10 +158,10 @@ to a domain you don't yet understand, where the boundaries themselves are still {% include excalidraw.html file="20-cynefin-coupling" - alt="The four Cynefin domains, each with its coupling posture. Complex (probe, sense, respond): keep coupling loose and reversible — favour events and contracts, and don't split prematurely. Complicated (sense, analyse, respond): design boundaries deliberately with DDD context mapping and invest in the right contracts. Chaotic (act, sense, respond): stabilise first with strong, local coupling, even a monolith, then refactor as it clarifies. Clear (sense, categorise, respond): standardise on contract coupling and codify the known-good with registry-checked schemas." + alt="The four Cynefin domains, each with its coupling posture. Complex (probe, sense, respond): keep coupling loose and reversible — favour events and contracts, and don't split prematurely. Complicated (sense, analyse, respond): design boundaries with DDD context mapping and invest in the right contracts. Chaotic (act, sense, respond): stabilise first with strong, local coupling, even a monolith, then refactor as it clarifies. Clear (sense, categorise, respond): standardise on contract coupling and codify the known-good with registry-checked schemas." caption="Figure G.5 — Cynefin and coupling: match the posture to how well you understand the domain" %} -The throughline with the rest of the appendix: in a **complex** domain you keep +The throughline of the coupling model: in a **complex** domain you keep strength low and reversibility high because you *will* move the boundaries; once the domain becomes **clear**, you can afford to standardise on the loosest workable strength — contract coupling — and freeze it. The anti-pattern is treating an early, complex @@ -239,5 +239,5 @@ it alone — which is the appendix's real lesson: looser is not automatically be *Verification status: unverified — this is a conceptual appendix with no runnable example; its claims are model-level (Khononov's *Balancing Coupling*) rather than code that executes. There is no `examples/` runner for it; verification here means a design -review confirms the three-dimension scoring matches how changes actually propagate in a +review confirms the three-dimension scoring matches how changes propagate in a real system.* diff --git a/_docs/21-appendix-h-shutdown.md b/_docs/21-appendix-h-shutdown.md index 7e7f5b5a..3e915943 100644 --- a/_docs/21-appendix-h-shutdown.md +++ b/_docs/21-appendix-h-shutdown.md @@ -12,7 +12,7 @@ This appendix is the operational flip side of factor IX, disposability, from the Cloud-Native Principles chapter. 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 scatter of `5xx` blips users actually notice during an otherwise-healthy rolling +the scatter of `5xx` blips users notice during an otherwise-healthy rolling deploy. Graceful shutdown is not a single setting; it is a short protocol the app and the platform perform together, on a clock: stop taking new work, finish what you accepted, drain consumers and sockets, then exit before the grace period runs out. @@ -48,7 +48,7 @@ The pod's side of the contract is five rules: - There is a **race** — endpoint removal is asynchronous — so a short `preStop` sleep covers the lag while the rest of the cluster stops routing to you. - **Liveness must stay UP** during the drain, or the kubelet restarts a pod that is - deliberately winding down. This is the distinction people get backwards: you *want* + winding down. This is the distinction people get backwards: you *want* readiness to fail, but you must *not* let liveness fail. - Size the grace period **larger than your longest acceptable in-flight operation** — too short cuts work off mid-flight, too long stalls every deploy. @@ -288,7 +288,7 @@ spec: `terminationGracePeriodSeconds` is the total budget, and it must exceed `preStop` plus your worst-case drain plus a buffer. The readiness probe's `periodSeconds × failureThreshold` -sets how fast Kubernetes actually stops routing after you report `DOWN` — roughly ten +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 cleanly winding down. The `preStop` sleep looks crude, but it is the pragmatic fix for the @@ -306,7 +306,7 @@ it reconnect to another pod, exactly what the WebSockets appendix's reconnection enables. Jobs and schedulers stop starting new work and checkpoint so they resume after restart. And the philosophical anchor of the whole appendix: you *cannot* guarantee a graceful exit, because the kubelet will sometimes `SIGKILL` you — so idempotency is what -actually makes the system safe, and graceful shutdown just makes the unsafe window rare. +makes the system safe, and graceful shutdown just makes the unsafe window rare. ## Graceful shutdown checklist @@ -336,8 +336,8 @@ resets timed to the restart, walk the protocol backwards — the usual culprit i not flipping early enough, or a `preStop`/grace-period that is shorter than the real drain. For the Kafka path, restart a consumer pod mid-stream and confirm from the broker that the group rebalanced quickly and offsets advanced — no long stall, no large redelivery spike. -A clean rollout under live load, not a green integration test, is graceful shutdown actually -working. +A clean rollout under live load, not a green integration test, is evidence that graceful shutdown +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) diff --git a/_docs/22-appendix-i-l7-routing.md b/_docs/22-appendix-i-l7-routing.md index b140e989..a243b869 100644 --- a/_docs/22-appendix-i-l7-routing.md +++ b/_docs/22-appendix-i-l7-routing.md @@ -15,7 +15,7 @@ crossing the network, both north/south (clients into the system) and east/west ( to service). The chapter walks what each layer can see and decide, shows real config for the mesh, covers TLS, sticky sessions, traffic steering, and the latency budget, then goes *inside* the service for rule-driven routing, and finally circles back to east/west, -where most of the decisions actually happen. +where most of the decisions happen. ## L4 versus L7 — what each layer can decide on @@ -24,7 +24,7 @@ a backend and hash-balance, but it cannot read URLs, headers, cookies, methods, metadata. A Layer-7 router sees the whole *conversation*: host, path, headers, cookies, JWT claims, query parameters, the HTTP method, and the gRPC `service.method` pair. That full envelope is the menu of inputs every routing decision in this appendix draws on. The -one nuance worth holding onto is TLS: an L4 balancer can route by SNI *without* terminating +one nuance is TLS: an L4 balancer can route by SNI *without* terminating TLS (cheap, opaque), while an L7 router typically terminates TLS so it can read everything else (richer, slightly slower). @@ -36,11 +36,11 @@ else (richer, slightly slower). ## The four-layer stack L7 routing lives in four layers, and each is the right home for a *different kind* of -decision. A request is really a chain of independent L7 decisions across them. +decision. A request is a chain of independent L7 decisions across them. {% include excalidraw.html file="22-l7-layers" - alt="North/south: a client passes through edge/ingress (TLS, host/path), then the API gateway (auth, rate limit), then a mesh sidecar (mTLS, retries), then the service (in-app rules). East/west: service A and service B talk through mesh sidecars that add mTLS, outlier detection, and locality routing. Each layer makes a different kind of decision and emits a trace span." + alt="North/south: a client passes through edge/ingress (TLS, host/path), then the API gateway (auth, rate limit), then a mesh sidecar (mTLS, retries), then the service (in-app rules). East/west: service A and service B talk through mesh sidecars that add mTLS, outlier detection, and locality routing. Each layer makes a distinct decision and emits a trace span." 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 @@ -50,13 +50,13 @@ transformation. The **service mesh** (Istio with Envoy sidecars, or Linkerd) gov east/west traffic, giving every internal call mTLS, retries, outlier detection, and locality-aware load balancing. And when a decision depends on business rules the platform cannot know, routing pushes into the **app** itself. Each box can refuse, transform, or -branch the request, and each emits a trace span — so the journey from the Observability +branch the request, and each emits a trace span — so the path from the Observability chapter, viewed through the L7 lens, is observable end to end. {% include excalidraw.html file="22-l7-journey" - alt="A request's L7 journey across five components: Client (request), Edge LB (TLS, host and path), API Gateway (auth, rate-limit), Mesh sidecar (mTLS, retry, locality), and the Service (in-app routing). Each box can refuse, transform, or branch the request and emits a trace span for the next layer." - caption="Figure I.3 — A request's journey: Client → Edge LB → API Gateway → Mesh sidecar → Service, each making its own L7 decision" %} + alt="A request's L7 path across five components: Client (request), Edge LB (TLS, host and path), API Gateway (auth, rate-limit), Mesh sidecar (mTLS, retry, locality), and the Service (in-app routing). Each box can refuse, transform, or branch the request and emits a trace span for the next layer." + caption="Figure I.3 — A request's path: Client → Edge LB → API Gateway → Mesh sidecar → Service, each making its own L7 decision" %} ## Content-based routing @@ -112,7 +112,7 @@ one pod: the balancer sets a cookie and routes subsequent requests back to the s but that ties the session to one pod's lifetime, so a restart loses it. **IP-hash** needs no cookie but is fragile behind NAT and mobile networks, where many users share an address and load skews. The real cost is that stickiness fights elastic scaling, fast restarts, and -graceful shutdown — the entire cloud-native delivery model. Treat it as a deliberate choice +graceful shutdown — the entire cloud-native delivery model. Treat it as a choice made for a known constraint, never a default, and prefer to externalise state (Redis for session data, JWT claims for identity, the backplane from the WebSockets appendix for fan-out) so the balancer can return to no-affinity. @@ -587,7 +587,7 @@ TLS termination is sub-2ms, path/header routing sub-1ms, JWT validation a few ms more, each mesh sidecar about a millisecond, a rate-limit Redis lookup a couple of ms. Body inspection is the outlier at roughly 5–50ms depending on size. The working heuristic is to stay under about 10ms of total L7 on hot paths — and with four layers (edge, gateway, sidecar -out, sidecar in) that means every layer must earn its place. The common pitfalls follow from +out, sidecar in) that means every layer must justify its latency. The common pitfalls follow from this: too many hops nobody measured until p99 spiked; expensive regex matches; sticky sessions fighting the platform; body inspection blowing the budget; per-request auth lookups cascading under load; and timeout misalignment, which is the gateway-to-mesh-to-service version of the @@ -596,8 +596,8 @@ 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 earn its place." - caption="Figure I.7 — Each L7 stage adds latency; stay under ~10 ms of total L7 on hot paths, so every hop must earn its place" %} + 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." + 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 @@ -644,7 +644,7 @@ spec: ``` That last block is a canary release of a backend service no external client knows exists — -something you simply cannot do through an edge load balancer. gRPC east/west has a few sharp +something you cannot do through an edge load balancer. gRPC east/west has a few sharp edges worth naming: route on the gRPC URI prefix for per-method canaries; prefer least-request load balancing over round-robin, because gRPC connections are long-lived and round-robining connections (rather than requests) can starve backends; be careful retrying streaming RPCs, @@ -677,7 +677,7 @@ from the broker that the VIP order landed on `orders.priority` (and triggered th HTTP call) while the ordinary one fell through to `orders.default` — then change only the ruleset, not the dispatcher, and confirm the routing changes without a redeploy. Weights that track the config and a ruleset that can move routing without a code change are the two halves of -this appendix actually working. +this appendix working. --- *Verification status: verified — [`examples/22-l7-routing/`](https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/22-l7-routing/) passes 15/15 checks in each of the six languages on Docker Engine (2026-10-10) diff --git a/_docs/23-appendix-j-dotnet-toolkit.md b/_docs/23-appendix-j-dotnet-toolkit.md index 8485e2ef..1b4dcc5b 100644 --- a/_docs/23-appendix-j-dotnet-toolkit.md +++ b/_docs/23-appendix-j-dotnet-toolkit.md @@ -4,7 +4,7 @@ marker: "J" label: "Appendix J" order: 23 part: "Deep-dive appendices" -description: ".NET-only reference: the pinned .NET 10 library and platform bill of materials, a Windows-to-Kubernetes migration table, an honest list of ecosystem gaps, and a six-library recipe for a new cloud-native service." +description: ".NET-only reference: the pinned .NET 10 library and platform bill of materials, a Windows-to-Kubernetes migration table, a list of ecosystem gaps, and a six-library recipe for a new cloud-native service." duration: 18 minutes --- @@ -15,7 +15,7 @@ duration: 18 minutes This is the reference appendix: a consolidated bill of materials for the application side, the same for the platform side, an expanded migration table with a one-line -rationale per row, a candid look at what .NET genuinely lacks, and a short recipe to +rationale per row, a look at what .NET lacks, and a short recipe to start a new service. It is built for the .NET-developers-moving-from-Windows audience, and its through-line is that the library choices and the platform choices are entirely separable — the cluster does not know or care that the Pods happen to run .NET. @@ -58,7 +58,7 @@ cloud. | Scheduling | Quartz.NET | Cron / cluster-aware job scheduling | Apache 2.0 | Quartz | | Scheduling | DistributedLock | Distributed mutex over Postgres / Redis / SQL Server | MIT | DistributedLock.* | -A few choices are worth calling out. `Microsoft.AspNetCore.OpenApi` and SignalR are built +`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 @@ -97,13 +97,13 @@ the application layer. 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 -stack on which .NET 10 is simply one workload type. +stack on which .NET 10 is one workload type. ## From Windows / .NET 4.x to Kubernetes / .NET 10 This is the expanded version of the bridge from the Cloud-Native Principles chapter — twenty-two rows, four columns. Read the rightmost column, "what changes most," because that -is where the architectural shift actually lives. Almost every row preserves the *shape* of +is where the architectural shift lives. Almost every row preserves the *shape* of the pattern: you still have request handlers, background workers, scheduled jobs, distributed locks, health checks, configuration sources, and telemetry. What changes is who owns the substrate — IIS owned the host, Kubernetes does now; the substrate becomes @@ -139,14 +139,14 @@ open-source and cluster-native. | 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 | -Only two rows are genuine conceptual rewrites rather than substrate swaps. **COM+ / MSDTC +Only two rows are conceptual rewrites rather than substrate swaps. **COM+ / MSDTC distributed transactions become sagas** — you write compensations explicitly because there is no two-phase commit across services. And **BizTalk orchestration becomes a Temporal workflow or an NRules routing pipeline** — orchestration is now C# code in your repository, not a separately-authored artifact in a separate runtime. Everything else is the platform owning more and your code owning less. -## What the .NET cloud-native story genuinely lacks +## 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. @@ -163,23 +163,23 @@ in each case there is a workable .NET path, a polyglot path, or a commercial pat - **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. -- **Deliberately simple DI.** Microsoft.Extensions.DependencyInjection is intentionally - minimal. For property injection, decorators, or advanced lifetime scopes, Autofac or - Lamar are drop-in replacements. +- **Minimal built-in DI.** Microsoft.Extensions.DependencyInjection covers constructor + injection only. For property injection, decorators, or advanced lifetime scopes, Autofac + or Lamar are drop-in replacements. - **MassTransit v9 is commercial.** The previously open-source service bus moved to a commercial license, so this book uses Confluent.Kafka directly for Kafka messaging and Wolverine (MIT) where a framework bus is wanted. - **MediatR is no longer open source.** It moved to a commercial license, and Wolverine is - the modern MIT-licensed equivalent — arguably a better design — for new projects. + the modern MIT-licensed equivalent, and a better design, for new projects. -The honest summary: the .NET cloud-native story is solid for well over 95% of line-of-business +The .NET cloud-native story is solid for well over 95% of line-of-business and SaaS workloads. The remaining few percent — integration platforms with heavy mainframe, EDI, or HL7 needs, or central heavy stream processing — is where you mix runtimes or accept a different trade-off. ## The five-line recipe for a new service -The steps are deliberately ordered — instrument first, then choose a protocol surface, then +The order matters: instrument first, then choose a protocol surface, then settle data ownership, then pick a workflow tier, then let the cluster do the heavy lifting: 1. **Instrument from line one.** `dotnet new web`; add `Microsoft.AspNetCore.OpenApi`, @@ -187,11 +187,11 @@ settle data ownership, then pick a workflow tier, then let the cluster do the he checks at `/healthz/live` and `/healthz/ready`; expose `ActivitySource`-based telemetry immediately. 2. **Pick exactly the protocols you need.** Minimal APIs for REST at the edge, - `Grpc.AspNetCore` for internal service-to-service, HotChocolate only if you genuinely + `Grpc.AspNetCore` for internal service-to-service, HotChocolate only if you need GraphQL aggregation. Do not surface all three by default. 3. **Settle data ownership.** EF Core for the OLTP store, with the outbox table in the same `DbContext` as your aggregates; Debezium to Kafka via Strimzi. Add a bus framework only when - you actually need bus abstraction, or use `Confluent.Kafka` directly for tighter control. + you need bus abstraction, or use `Confluent.Kafka` directly for tighter control. 4. **Choose a workflow tier.** Temporal .NET SDK when the saga is durable, long, or cross-service; Stateless when it is small and in-process. Persist *every* transition; never trust in-memory state to survive a Pod restart. @@ -205,7 +205,7 @@ SignalR per workload — only when the use case earns it. ### Cross-check it yourself -This appendix is reference material, so the check is that the recipe actually stands up. +This appendix is reference material, so the check is that the recipe stands up. Scaffold the six-library service and confirm the day-one wiring end to end: `dotnet new web`, add the six packages, map `/healthz/live` and `/healthz/ready`, and start it. Hit both health endpoints with curl and confirm `200`s with the expected probe payloads. Then issue one real diff --git a/_docs/24-appendix-k-monolith-to-microservices.md b/_docs/24-appendix-k-monolith-to-microservices.md index 66853362..a34fe84e 100644 --- a/_docs/24-appendix-k-monolith-to-microservices.md +++ b/_docs/24-appendix-k-monolith-to-microservices.md @@ -24,7 +24,7 @@ Define the term before fixing it. A monolith is not a code-organisation problem *deployment* property. A clean codebase with clear modules, layers, and tests is still a monolith if it ships as one binary on one heartbeat. That property has real costs: a change anywhere means redeploying everything, a bug in reporting can take down payments, and one -scaling unit has to serve very different workloads. But the costs do not make monoliths +scaling unit has to serve different workloads. But the costs do not make monoliths *wrong*. For a new product, a small team, or anything that fits in one team's head, a monolith is often the simplest and fastest path to value, and premature decomposition is its own anti-pattern. The real question is never "monolith or microservices?" in the abstract — @@ -33,7 +33,7 @@ patterns earn their keep. {% include excalidraw.html file="24-monolith-unit" - alt="A monolith as a unit of deployment. Web/mobile and other systems call into a single deployable that contains every module — auth, orders, inventory, catalog, payments, shipping, reporting, notifications, admin, pricing, returns, search — all sharing one database that every module reads and writes, giving schema-level coupling everywhere. A change anywhere means redeploying everything, a bug in reporting can take down payments, and one scaling unit serves very different workloads — but it is often the simplest, fastest path to value when the system fits in one team's head." + alt="A monolith as a unit of deployment. Web/mobile and other systems call into a single deployable that contains every module (auth, orders, inventory, catalog, payments, shipping, reporting, notifications, admin, pricing, returns, search), all sharing one database that every module reads and writes, giving schema-level coupling everywhere. A change anywhere means redeploying everything, a bug in reporting can take down payments, and one scaling unit serves different workloads. It is often the simplest, fastest path to value when the system fits in one team's head." caption="Figure K.1 — A monolith is a deployment property: one binary, one heartbeat, one shared database — every module ships and scales together" %} ## The modular monolith — often the destination @@ -47,7 +47,7 @@ another's internals (import-linter or modulegraph rules in Python; module-system build-dependency rules on the JVM and .NET). You get most of the benefit of microservices — clear boundaries, replaceable modules, independent reasoning — at a fraction of the operational cost: one deployment, one log stream, one debugger session. And if you later need -to split for real, it is a small refactor (rename the public API, move the files, ship) not a +to split, it is a small refactor (rename the public API, move the files, ship) not a rewrite. {% include excalidraw.html @@ -55,7 +55,7 @@ rewrite. alt="A modular monolith, still a single deployable, holding an Orders module (public API PlaceOrder and GetOrder, private internals guarded by the build tool), an Inventory module (public API ReserveStock and CheckAvailability, no shared types), and a Payments module (public API ChargeCard and Refund, its own data schema). They share one database but with separate schemas — orders, inventory, payments — and no cross-schema joins, enforced. Modules talk only through their public API, enforced by linters and build rules rather than a gentlemen's agreement." caption="Figure K.2 — The modular monolith: one deployable, strict in-process boundaries enforced by build tooling, separate schemas" %} -The genuinely hard part of decomposition is the *database*, and the modular monolith lets you +The hard part of decomposition is the *database*, and the modular monolith lets you do it while still one process. The hardest coupling to see is in the data — schema dependencies, cross-table joins, and @@ -106,7 +106,7 @@ before it reaches the legacy system, giving yourself a place to redirect later. alt="Step zero of every migration: introduce a routing layer before extracting anything. Clients hit a proxy or reverse proxy (nginx, Envoy, HAProxy, or YARP) through which all traffic now flows, and the proxy forwards everything to the monolith, which still owns everything and doesn't know about the proxy. New services like an extracted Orders.svc and a future Inventory.svc are soon to come behind the proxy's future routes. There is zero behaviour change for clients on day one, but the lever to route traffic to extracted services is now in place, and the proxy is also where TLS, auth, rate-limiting, and observability consolidate." caption="Figure K.5 — The proxy is step zero: add the routing layer first, forwarding everything to the monolith, so later cutovers are a config change not a client release" %} -Once the proxy is in place it earns its keep by *redirecting* a slice — by path +Once the proxy is in place it pays off by *redirecting* a slice — by path (`/orders/*` → new service), by header (`X-Tenant: acme` → new service), by percentage, or by feature flag. The new service handles its slice and the monolith handles the rest, while clients still see one URL, one auth boundary, one TLS cert. Roll forward by widening the new-service @@ -120,14 +120,14 @@ used for migration instead of canary release. Sometimes the data split is too risky to do at the same time as the process split. Then you let the new service and the monolith share the database for a while: both read and write the same -tables, and the new service owns the *API* but not the *data*. This is genuinely useful for +tables, and the new service owns the *API* but not the *data*. This is useful for extracting reads first (the new service is a nicer API over a read replica) and as a stepping stone when the data migration is its own project. But it is a transition state, never a destination — every schema change now needs both apps to agree, and that coordination tax grows with every team that owns part of the same database. Plan its dissolution from the start: the next step is to give the new service its own store, via CDC out of the monolith's database, dual-writing during a brief sync window, or event-sourcing the data across. A shared-database -strangler with no exit plan simply becomes the new monolith. +strangler with no exit plan becomes the new monolith. {% include excalidraw.html file="24-shared-database" @@ -351,7 +351,7 @@ func forward(w http.ResponseWriter, r *http.Request, c *http.Client, upstream st The shape is identical everywhere: read the payload, pick an upstream by content rather than URL, and forward. Two implementation notes matter. The body is consumed when you read it, so you must hand the same bytes through to the upstream — the Python `forward` and the C++ `req->body()` -both do this deliberately. And the HTTP client is reused across requests so its connection pool +both forward the buffered body unchanged. And the HTTP client is reused across requests so its connection pool stays warm. The only real cost is the per-request body parse; over a migration measured in days it is irrelevant next to the risk it controls, but over months it adds up — which is why, in production, the same logic often moves into a sidecar's Lua/WASM filter or an `EnvoyFilter`. @@ -591,7 +591,7 @@ book-length treatment is Sam Newman's *Monolith to Microservices* (O'Reilly). ### Cross-check it yourself -Prove the two properties this appendix actually rests on: reversibility and equivalence. Stand the +Prove the two properties this appendix rests on: reversibility and equivalence. Stand the content router in front of a stub "monolith" and a stub "new service" that tag their responses, and drive traffic with `hey`. Send a batch with `tenant: acme` and confirm every one lands on the new service; send the rest and confirm they land on the monolith; then flip the rule (or widen the diff --git a/_docs/25-appendix-l-caching.md b/_docs/25-appendix-l-caching.md index c86b9c27..465ad00a 100644 --- a/_docs/25-appendix-l-caching.md +++ b/_docs/25-appendix-l-caching.md @@ -8,11 +8,11 @@ description: "Six standard caching patterns — cache-aside, read-through, write duration: 24 minutes --- -Caching is the highest-leverage performance tool you have and the easiest to get subtly -wrong. The six patterns in this appendix all use the same pieces — an application, a cache +Caching is one of the cheapest big performance wins, and the easiest to get +subtly wrong. The six patterns in this appendix all use the same pieces — an application, a cache (Redis here), and a database — and differ in exactly two things: *who does the work* and *what consistency story you get*. The discipline is to pick the pattern that matches the -consistency you actually need and the read/write profile you actually have, not the one +consistency you need and the read/write profile you have, not the one that sounds best. Every pattern here also adds an invalidation story and a failure-mode story, so the appendix ends on the anti-patterns and a decision guide for mixing them. @@ -624,7 +624,7 @@ func (s *Server) updateProduct(w http.ResponseWriter, r *http.Request) { When data is written far more often than it is read — audit logs, event records, telemetry — caching every write is wasteful, because most entries expire before anyone reads them. Write-around writes the database and leaves the cache alone; the next reader pays the miss and populates it. The cache -then holds only things actually being read, with no pollution by write-only data. The cost is that +then holds only things being read, with no pollution by write-only data. The cost is that the first read after every write is slow, so you pair write-around on the write side with cache-aside or read-through on the read side. It is the right pattern for any append-heavy or mostly-written workload. @@ -804,7 +804,7 @@ equals cache latency (microseconds), which is enormous throughput on write-heavy is durability: a cache crash before the flusher catches up loses unflushed writes, so the cache must be configured for persistence (Redis AOF or RDB) and you must have thought about what "lost the last few seconds of writes" means for the business. Reserve it for cases where write throughput is the -constraint *and* eventual durability is genuinely acceptable — real-time game state, telemetry with a +constraint *and* eventual durability is acceptable — real-time game state, telemetry with a downstream aggregator, write-heavy buffering ahead of a slower store. {% include excalidraw.html @@ -1320,15 +1320,15 @@ func (s *Server) refresher(ctx context.Context) { } ``` -## When NOT to cache, and what to watch for +## When not to cache, and what to watch for -Caching is a complexity tax, only worth paying when reads genuinely hurt — the cheapest cache is no +Caching is a complexity tax, only worth paying when reads hurt — the cheapest cache is no cache, so if reads are already fast, don't add one. The failure modes to design against: - **Don't cache rarely-read data.** Caching write-once-read-never data thrashes the cache and gains nothing. Profile read patterns first. -- **Don't cache without a TTL.** An unbounded cache grows until it evicts at random — the worst kind of - failure, because it is unpredictable. Always set an explicit TTL or a `maxmemory-policy` +- **Don't cache without a TTL.** An unbounded cache grows until it evicts at random, a failure that is + hard to diagnose because it is unpredictable. Always set an explicit TTL or a `maxmemory-policy` (`allkeys-lru` is the typical default); an unbounded Redis with no eviction policy will OOM-kill itself. - **Avoid cache stampedes (thundering herd).** When a hot key expires and many concurrent readers all @@ -1353,11 +1353,11 @@ extreme-write-throughput pattern, valid only when eventual durability is accepta configured for persistence. **Refresh-ahead** is the hot-key pattern, eliminating user-visible misses on a small identified hot set at the cost of extra DB load on it. Most production systems run cache-aside for the bulk of traffic, layer refresh-ahead on the hot keys they have identified, and use write-through -only where stale reads are genuinely forbidden — a mix, not a single rule. +only where stale reads are forbidden — a mix, not a single rule. ### Cross-check it yourself -Prove the two properties that matter: the cache actually offloads the database, and a cache outage +Prove the two properties that matter: the cache offloads the database, and a cache outage degrades rather than breaks. Put read load on the cache-aside endpoint with `hey` and watch the database's query counter (Postgres `pg_stat_statements`, or the query log): after the first miss per key the DB call count should flatten while throughput climbs, and p99 should drop sharply — that is the diff --git a/_docs/26-appendix-m-failure-modes.md b/_docs/26-appendix-m-failure-modes.md index b2e0b68e..45c2c31d 100644 --- a/_docs/26-appendix-m-failure-modes.md +++ b/_docs/26-appendix-m-failure-modes.md @@ -4,7 +4,7 @@ marker: "M" label: "Appendix M" order: 26 part: "Deep-dive appendices" -description: "The synthesis appendix — a taxonomy of how distributed systems actually fail (partition, split-brain, partial, gray, amplification, cascading) and the defensive toolkit mapped to each: timeouts and deadlines, retry with backoff and jitter, circuit breakers, bulkheads, load shedding, quorum, and the mesh." +description: "The synthesis appendix — a taxonomy of how distributed systems fail (partition, split-brain, partial, gray, amplification, cascading) and the defensive toolkit mapped to each: timeouts and deadlines, retry with backoff and jitter, circuit breakers, bulkheads, load shedding, quorum, and the mesh." duration: 24 minutes --- @@ -29,12 +29,12 @@ errors). Everything that follows comes from accepting this and designing to boun ## A taxonomy of failure modes -The failures sort into three families, and the rest of the appendix is the toolkit that sits +The failures sort into three families, and the defensive toolkit sits underneath them. {% include excalidraw.html file="25-failure-taxonomy" - alt="Three families feed into a distributed system: NETWORK (partition, split-brain — alive but cannot talk), COMPONENT (partial, gray failure — some fail, some lie), and LOAD (amplification, cascading — a failure feeds on itself). Below sits the defensive toolkit: timeouts, retries, circuit breakers, bulkheads, load shedding, fallback, quorum, Istio, graceful shutdown, observability." + alt="Three families feed into a distributed system: NETWORK (partition, split-brain: alive but cannot talk), COMPONENT (partial, gray failure: some fail, some lie), and LOAD (amplification, cascading: a failure feeds on itself). The defensive toolkit answers them: timeouts, retries, circuit breakers, bulkheads, load shedding, fallback, quorum, Istio, graceful shutdown, observability." caption="Figure M.1 — Three families of distributed failure, over the defensive toolkit that addresses them" %} **Network** failures are partition and split-brain — nodes are alive but cannot talk. @@ -157,9 +157,8 @@ edge before it enters the system. ## The defensive toolkit -The rest of the appendix is the toolkit, and the thing to hold onto is that **no pattern is -sufficient alone** — several interact, and production resilience is their deliberate -composition. Retry without a circuit breaker amplifies; a circuit breaker without a fallback +**No pattern in the toolkit is sufficient alone** — several interact, and production +resilience is their composition. Retry without a circuit breaker amplifies; a circuit breaker without a fallback just fails faster; a bulkhead without a timeout still lets threads block within its pool. The patterns here each get a diagram and per-language code; the platform-level pieces (Istio, shutdown, observability) sit underneath the application-level ones. @@ -622,7 +621,7 @@ must be combined. The mesh provides bulkheading at the connection-pool level, co ## Load shedding and graceful degradation Two related patterns engineers tend to skip. **Load shedding** feels like giving up — -deliberately rejecting requests — but it is the difference between degrading and collapsing. The +choosing to reject requests — but it is the difference between degrading and collapsing. The arithmetic is simple: at 120% of capacity, accepting everything makes *everything* slow and timing out (0% goodput); shedding the excess 20% means the 80% you accept is served well. Shed by priority (drop health-check-style or low-value traffic first, protect revenue-critical paths), @@ -742,7 +741,7 @@ trips and the caller returns its fallback fast. **Deadline propagation:** issue small deadline header to a chain of two services and confirm from the logs that the *second* hop refuses to start work when the remaining budget has fallen under its minimum, rather than running a query whose result will be discarded. A breaker that flattens downstream load under failure, and a -deadline that stops doomed work early, are the two load-bearing behaviours of everything here. +deadline that stops doomed work early, are the two behaviours everything here depends on. This appendix touches difficult operational territory; if you are diagnosing a live incident, the Google SRE cascading-failures chapter is the fastest way to a shared vocabulary with your team. diff --git a/_docs/27-appendix-n-feature-flags.md b/_docs/27-appendix-n-feature-flags.md index 7902cb7e..10457099 100644 --- a/_docs/27-appendix-n-feature-flags.md +++ b/_docs/27-appendix-n-feature-flags.md @@ -35,7 +35,7 @@ mechanism behind every canary and every safe cutover in the book. ## Four kinds of flag -"Feature flag" covers four genuinely different tools, and conflating them is the root of most +"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. {% include excalidraw.html @@ -48,10 +48,10 @@ and then be removed. **Experiment (A/B) flags** are temporary and data-owned: th across variants so a metric can be measured, and they *require* sticky assignment. **Ops / kill-switch flags** are long-lived and SRE-owned: a permanent control to disable a feature or a dependency fast under load or incident. **Permission / entitlement flags** are long-lived and -product-owned: they gate functionality by plan, tier, or region, and are really part of the +product-owned: they gate functionality by plan, tier, or region, and are part of the product model rather than a rollout. The defaults differ too: a release flag fails *off* (new code stays dark if the flag system is unreachable), while a kill-switch usually fails *on* (the -feature stays available unless someone deliberately kills it). +feature stays available unless someone kills it). ## Evaluating a flag: OpenFeature and flagd @@ -59,7 +59,7 @@ Evaluation should not couple your code to a vendor. **OpenFeature** is the CNCF 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, consistent with the rest of this book; swapping flagd for another provider later is a +SaaS, consistent with the open-source stack used throughout the book; swapping flagd for another provider later is a configuration change, not a code change. {% include excalidraw.html @@ -269,9 +269,9 @@ interval, with no redeploy. ## The kill switch 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 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. +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; the kill switch is flipped by a person. {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} @@ -364,7 +364,7 @@ is dead code, a hidden conditional, and a live risk surface that nobody remember 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 +cleanup. Long-lived ops and entitlement flags are exempt because they are meant to be permanent, which is exactly why distinguishing the four types matters. A short-lived flag that has reached 100% for two weeks should be deleted along with the branch it guarded. @@ -373,7 +373,7 @@ for two weeks should be deleted along with the branch it guarded. 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 deliberately. Observability ties flags back to the +environments unless a test sets them explicitly. Observability ties flags back to the Observability chapter: 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 new checkout's p99 is worse for the 25% on `on`" is only visible if the variant is on the telemetry. The recurring diff --git a/_docs/28-appendix-o-newman.md b/_docs/28-appendix-o-newman.md index dee85286..68d4e486 100644 --- a/_docs/28-appendix-o-newman.md +++ b/_docs/28-appendix-o-newman.md @@ -9,7 +9,7 @@ duration: 20 minutes --- Postman is already one of the tools this book reaches for to exercise an API by hand. **Newman** -is its command-line runner: it executes the very same Postman collection headlessly, which turns +is its command-line runner: it executes the same Postman collection headlessly, which turns the requests your team already pokes at interactively into an automated test gate you can run on every change. The payoff is that the assertions live in one place — the collection — and the same file serves three audiences: a developer clicking through Postman, a reviewer reading the tests, @@ -80,7 +80,7 @@ implementation in this book. alt="One collection running under Newman tests the same HTTP API (/orders, /payments) against six order-service implementations — Spring Boot, Quarkus, .NET, Python, C++, and Go. Because Newman tests the API as a black box, one collection validates every implementation; the contract is the spec, not the code." caption="Figure O.3 — One collection is the conformance suite for all six implementations" %} -This is a quietly powerful idea: the collection *is* the API specification made executable. Point +The collection *is* the API specification made executable. Point it at whichever implementation is running and the assertions are identical. Bringing the service under test up is the only language-specific step; the Newman command that follows is the same. @@ -148,7 +148,7 @@ This is how you cover boundary values, bad input, and edge cases without duplica {% include excalidraw.html file="27-data-driven" - alt="A collection of one request set, combined with an iteration-data.csv of rows (id and qty values including a zero and a very large number), runs once per row under newman, producing N result sets — one per case." + alt="A collection of one request set, combined with an iteration-data.csv of rows (id and qty values including a zero and a large number), runs once per row under newman, producing N result sets — one per case." caption="Figure O.4 — Data-driven runs: one request set, many inputs via --iteration-data" %} ```bash @@ -192,12 +192,12 @@ it appears. ## Newman in CI The runner exits non-zero when any assertion fails, which is all a pipeline needs to gate a merge. -The services come up on Docker Engine exactly as they do on a laptop — no managed cloud — and Newman runs +The services come up on Docker Engine exactly as they do locally — no managed cloud — and Newman runs against them. {% include excalidraw.html file="27-ci-gate" - alt="A pipeline: pull request, build image, docker compose up of the order/payment/inventory services, newman run of the collection, then a gate where pass merges and fail blocks. The services run on Docker Engine in CI exactly as on a laptop, and a failed assertion fails the build." + alt="A pipeline: pull request, build image, docker compose up of the order/payment/inventory services, newman run of the collection, then a gate where pass merges and fail blocks. The services run on Docker Engine in CI as they do locally, and a failed assertion fails the build." caption="Figure O.5 — Newman as a merge gate: bring the stack up on Docker, run the collection, fail the build on a broken contract" %} ```yaml @@ -236,13 +236,13 @@ API/contract tier: real HTTP against a running service, fast enough to run on ev 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 -journeys that genuinely need the whole system. Pushing everything into Newman makes a slow, +journeys that need the whole system. Pushing everything into Newman makes a slow, brittle suite; ignoring it leaves the contract untested. ## Negative and authentication testing The cases that matter most are the ones that are easy to skip. Assert the **failure** paths as -deliberately as the happy path: a `401` with a `WWW-Authenticate` header when the token is missing, +carefully as the happy path: a `401` with a `WWW-Authenticate` header when the token is missing, a `403` when it is present but unauthorised, a `409` or `422` on a business-rule violation, and a `429` carrying `RateLimit` headers when a client exceeds its budget. Confirm idempotency by sending the same `Idempotency-Key` twice and asserting the second response matches the first rather than @@ -267,7 +267,7 @@ JSON-schema validation. Prove the gate end to end against the running stack. Bring the services up with `docker compose up -d --build --wait`, then `newman run` the orders collection with `--reporters cli,junit` and confirm a green run and a -`newman.xml` you could hand to CI. Now make the contract break on purpose — have the service return +`newman.xml` you could hand to CI. Now break the contract — have the service return `200` instead of `201` on create, or drop the `id` field — and confirm Newman exits non-zero and the failing assertion names the exact expectation. Finally, add a negative row to your `--iteration-data` (a zero-quantity order expecting `422`) and confirm it passes against a correct diff --git a/_docs/29-appendix-p-dotnet-on-linux.md b/_docs/29-appendix-p-dotnet-on-linux.md index 184e63d8..f6a34031 100644 --- a/_docs/29-appendix-p-dotnet-on-linux.md +++ b/_docs/29-appendix-p-dotnet-on-linux.md @@ -46,7 +46,7 @@ runtime, the BCL, and the ASP.NET Core framework that sits on top. When it says means the language syntax. A `.csproj` file targets a .NET version (`net10.0`), not a C# version, because the platform version determines what APIs are available. -## CIL: what `dotnet build` actually produces +## CIL: what `dotnet build` produces When Roslyn compiles a C# source file, the output is not x86 machine code. It is **Common Intermediate Language** (CIL, historically called MSIL or just IL) — a diff --git a/_docs/29-glossary.md b/_docs/29-glossary.md index 67409dd2..a62b5970 100644 --- a/_docs/29-glossary.md +++ b/_docs/29-glossary.md @@ -11,7 +11,7 @@ Every acronym the book leans on, expanded and put in context. They are grouped b layer they belong to — the wire, the data store, messaging, the platform, security, observability, and the domain — so a term is easy to find by the part of the system it describes. Where an acronym means something specific *in this book*, the third column says -what, because a few of them (notably `ACL`) carry a different meaning here than the one you +what, because a few of them (`ACL` among them) carry a different meaning here than the one you might expect. ## Protocols, APIs, and the wire diff --git a/_example_pages/prerequisites.md b/_example_pages/prerequisites.md index 062a79d3..023af156 100644 --- a/_example_pages/prerequisites.md +++ b/_example_pages/prerequisites.md @@ -328,7 +328,7 @@ done --- -## What you do NOT need +## What you do not need All application code runs inside containers built from **Red Hat UBI** base images. You do not need to install any of these locally to run the examples: diff --git a/_plans/modernization-plan.md b/_plans/modernization-plan.md index 7e53ad24..b7e77505 100644 --- a/_plans/modernization-plan.md +++ b/_plans/modernization-plan.md @@ -32,8 +32,8 @@ Each step gets its own branch, a PR, green CI and a squash-merge before the next | S7 | chore/cpp-toolchain-ubi10 | 2+4 | done (111/111 PASS after two Opus repair rounds; closes #35) | | 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 | validated (PR pending) | -| S11 | docs/professional-voice | 9 | pending | +| 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 | ## Target pins (verified 2026-10-09; re-check at the start of each branch) @@ -90,6 +90,7 @@ Each step gets its own branch, a PR, green CI and a squash-merge before the next - Teardown removed the operator, 10 CRDs, namespaces and the registry route change; `crc stop` (not deleted); second teardown exit 0; current-context unset and `~/.kube/config` sha256 unchanged. - Details: `_plans/s10-openshift-local-plan.md`, "Live results". - S11: voice scan reports zero ban-tier hits and is enforced in CI. + Evidence (2026-10-11): ban 95 -> 0 and watch 239 -> 108 over the gated scope (scanner lgtm-skills a335007, vendored in `scripts/voice/`); per-file table and ledgers in `_plans/s11-professional-voice-plan.md`; gate = `scripts/voice-scan.sh` step in `content.yml` (`content-ok`). - S12 (scope expanded by the user 2026-10-11): review every presentation in `lgtm-presentation/` (the six Designing-Cloud-Native-APIs decks, K8s-Environment-Access-Policies, Network-Policy-Process-Advisory, and their .md/.docx companions) for: - version changes (every version on a slide matches the S2–S8 pins); - stack changes (Docker Engine, not the old container engine; locked libraries such as Streamiz, faust-streaming, goka, librdkafka C++, C++23; kafka-ui removed); diff --git a/_plans/s11-professional-voice-plan.md b/_plans/s11-professional-voice-plan.md new file mode 100644 index 00000000..5657261f --- /dev/null +++ b/_plans/s11-professional-voice-plan.md @@ -0,0 +1,211 @@ +# S11 plan — professional-voice pass and CI voice gate + +Approved by the user 2026-10-11. Opus plan; Sonnet executes in batches on `docs/professional-voice`; Opus validates. + +## User decisions (2026-10-11) + +1. Fix the scanner upstream first, then vendor it: lgtm-skills PR #48 (50b4c08: scratch file outside the scanned mirror; `candid` flagged), then PR #49 from validation (a335007: "the rest of the appendix/section", adjective "deliberate", summary markers). Vendored at `a3350074a1df343b3bbd58a563f26bf0311a265a`. +2. Fix watch tier too, with the triage policy below; every kept watch hit gets a reason code. Only ban tier is gated in CI. +3. `_plans/` stays internal history: not rewritten, not gated, still published. + +## Baseline (main 48e7736, scanner 50b4c08) + +- Whole repo: 338 lines, 91 ban (the extra one is `candid`, Appendix J line 18). In scope: 90 ban (24 `_docs` chapters plus 8 SVGs); out of scope: `lgtm-presentation/BUILD.md:52` (S12). +- In-scope watch: about 225 (199 site and diagrams, 26 `examples/**/*.md`). +- No ban hit is inside code or recorded output. Hits sit in prose, front-matter `description:`, `{% include excalidraw.html %}` alt/caption attributes, and SVG text. +- SVGs without a generator in the repo (hand-edit SVG ``, `.excalidraw` `text` and `originalText`, and the doc alt/caption together): 04-cqrs, 05-checkpoint-recovery, 17-saga-state, 20-cynefin-coupling, 21-shutdown-sequence, 22-east-west, 22-hop-cost, 25-istio-resilience. 27-ci-gate comes from `scripts/gen_27_diagrams.py`. + +## Scope (gated by `scripts/voice-scan.sh`) + +| Surface | Status | +|---|---| +| `_docs/*.md`, `_parts/*.md`, `index.html`, `_example_pages/*.md`, `_includes/*.html`, `_layouts/*.html`, `README.md`, `assets/diagrams/*.svg` | in, gated (default extensions) | +| `examples/**/*.md` | in, gated (`--ext md`, so example source is not scanned) | +| `scripts/gen_*.py` label strings | edited only to keep a regenerated SVG in step; not gated | +| `verify.sh`, `k8s/*.sh`, `openshift/*.sh`, workflows, compose and YAML | out (code; echo text is recorded output) | +| `lgtm-presentation/` | out; S12 switches it on (commented line in the wrapper) | +| `_plans/`, `CLAUDE.md` | out (internal / agent-facing) | + +## Steps + +0. lgtm-skills PR #48 (done, 50b4c08). +1. Plan status (S10 done, S11 in progress), vendor `scripts/voice/{scan.sh,patterns.tsv}` byte-identical from 50b4c08 plus `scripts/voice/README.md` (provenance: repo, path, SHA, date, license note, resync command, "fix upstream"), wrapper `scripts/voice-scan.sh` (two invocations: site roots with default extensions; `examples` with `--ext md`; no args = gate mode `--tier ban --fail` with `--hits all --tier ban` excerpts on failure and combined exit code; args pass through; `--all-tiers-summary`). No CI change yet. +2–7. Sonnet sweep batches (whole files; a chapter's SVG, `.excalidraw` and alt/caption travel together; checkpoint commit per batch): + - B1 `_docs/05-event-driven.md` + diagrams 05-* + - B2 `_docs/00`–`13` + diagrams 04-*, 08-*, 12-*, 13-* + - B3 appendices A–H (`_docs/14`–`21`) + diagrams 15-*, 17-*, 18-*, 19-*, 20-*, 21-* + - B4 appendices I–K (`_docs/22`–`24`) + diagrams 22-*, 24-* + - B5 appendices L–P and glossary (`_docs/25`–`29-*`) + diagrams 25-*, 27-* + - B6 appendices R–S (`_docs/31`, `_docs/32`), `_example_pages/*`, `_parts/*`, `index.html`, `README.md`, `examples/**/*.md` +8. CI gate in `.github/workflows/content.yml` job `validate` (feeds `content-ok`; no ruleset change): `bash scripts/voice-scan.sh` and `bash scripts/voice-scan.sh --all-tiers-summary`. CLAUDE.md documents the gate. Evidence table in `modernization-plan.md`. +9. Opus validation, repair rounds (max two), PR, CI green, squash-merge. + +## Executor rules + +1. Never edit inside fenced blocks, inline code, `{% raw %}` segments, codetab bodies, `{% include %}` `file=`/`langs=` attributes, URLs or link targets. Recorded output and result tables stay as they are. +2. Do not change meaning: every number, version, pin, claim and caveat survives. Rewrite the sentence, not the word. +3. Keep markdown structure: heading levels, list nesting, tables, wrapping style. +4. No positional words (above, below, preceding, following, next or previous section, later or earlier in this chapter). "The rest of this book/chapter" is rewritten by naming the concept or the chapter or appendix title. +5. Never name the old container engine. +6. "Verification status" footers: fix voice in the lead-in only, never the facts. +7. SVG triple rule: change the SVG ``, both `text` and `originalText` in the `.excalidraw`, and any doc alt/caption that repeats the label, identically. The new label is no longer than the old one. Keep XML escapes valid. +8. No synonym swaps: never introduce truly, candid, intentional, deliberate, purposely, purposefully, real-world, out of the box, at no cost; "by design" only when followed by the reason. +9. Edits stay within the flagged sentence and its neighbour. +10. Watch-tier triage: delete "actually" unless it contrasts with a stated wrong belief; delete "simply/trivially" ("easy to miss/forget" stays); delete "very/really/crucially/notably" ("very high" stays only in a comparison-table cell); "quietly/silently" stay with a machine subject, rewrite drama; split prose lines with 3+ em dashes (empty table cells stay); lowercase ALL-CAPS in prose and labels (NETWORK/COMPONENT/LOAD taxonomy labels stay); "isn't X — it's Y" only when X is a misconception readers hold; "landscape" (survey) and the `22-l7-journey` identifier stay, prose "journey" becomes "path", rewrite "highest-leverage"; commit instead of "arguably"; result tables stay, headings rewritten only when no anchor points at them; minikube/OpenShift stay where they are the subject, "laptop" becomes "workstation" or "locally"; "lie" with a machine subject stays; one "This chapter is about" opener per chapter stays; README "the roadmap and the Phase-3 backlog" becomes "internal planning notes". +11. Handback ledger in the report: per file ban and watch before/after; each kept watch hit with a reason code (`machine-subject`, `literal-subject`, `table-data`, `survey-term`, `idiom`, `identifier`, `misconception-contrast`, `chapter-opener`); every heading whose text changed. +12. Before handing back: `scripts/voice-scan.sh --tier ban ` is 0 and `python3 scripts/validate_site.py` reports 0 errors. + +## Acceptance criteria + +1. `bash scripts/voice-scan.sh` exits 0 (0 ban-tier in both invocations). +2. Whole-repo ban scan lists only `lgtm-presentation/BUILD.md`. +3. In-scope watch tier drops from about 225; every survivor has a reason code (expected roughly 90–110). +4. For every changed `.md`, the ordered lists of fenced blocks, inline code spans, `{% raw %}` segments, link targets and include `file=`/`langs=` attributes are identical between main and HEAD. +5. Every changed line is within 2 lines of a baseline hit, or is a listed alt/caption/SVG mirror. +6. Added lines contain no synonym swaps, positional words or the old container engine's name. +7. No meaning drift (word-diff read: all of 05, 23, 24, 27 plus one file per other batch). +8. `validate_site.py` 0 errors. +9. Jekyll build (ruby:3.3, production, baseurl) for main and HEAD: same generated files; same ids except headings listed in ledgers with no inbound anchors; same href/src/data values. +10. Changed SVGs pass `xmllint`; changed `.excalidraw` files are valid JSON with `text == originalText`; alt/caption agree; labels no longer than before; rendered and inspected. +11. Vendored files byte-identical to lgtm-skills a335007 (sha256), modes 100755/100644. +12. actionlint clean on `content.yml`; shellcheck clean on `scripts/voice-scan.sh`. +13. Gate proof (local, reverted): injection in a `_docs` page, in an `examples` README and in an SVG each exit 1; injection inside inline code and a fenced block exit 0. +14. mawk (ubuntu:24.04 container) and gawk give the same all-tier totals; the CI log shows them. +15. Plan shows S10 done and S11 validated with the per-file table; CLAUDE.md updated. +16. PR checks `site`, `content-ok`, `examples-ok` green, with the voice-scan step in the `content-ok` run log. + +## Status + +| Step | Status | +|---|---| +| 0 upstream fix | done (lgtm-skills #48, 50b4c08) | +| 1 vendor + wrapper | done (bf3f9d3) | +| 2–7 batches | done (B6 15f0f0a, B1 99243f4, B4 79e04a8, B2 d32a858, B5 a7d0874, B3 31d8eb7) | +| 8 gate | done | +| 9 validation, PR | validation round 1 done; repair round 1 (lgtm-skills #49 re-vendored, wrapper paths fix aebfb9e, wording 9450973); final recheck: merge verdict; its nits fixed (22-sticky-sessions label, Appendix L opener, chapter 00 and 04 wording, wrapper path resolution) | + +## Results + +Per-file counts from the final scanner (lgtm-skills a335007) run on main 48e7736 (before) and on this branch (after). These counts are authoritative; the batch ledgers further down are the executors' narrative and their hand counts used the earlier pattern set. + +| File | Ban before | Ban after | Watch before | Watch after | +|---|---|---|---|---| +| `README.md` | 0 | 0 | 2 | 1 | +| `_docs/00-setting-up.md` | 3 | 0 | 4 | 0 | +| `_docs/01-cloud-native-principles.md` | 2 | 0 | 2 | 0 | +| `_docs/02-communications.md` | 3 | 0 | 2 | 0 | +| `_docs/03-composition.md` | 0 | 0 | 2 | 0 | +| `_docs/04-data.md` | 2 | 0 | 3 | 1 | +| `_docs/05-event-driven.md` | 16 | 0 | 26 | 11 | +| `_docs/06-stream-processing.md` | 1 | 0 | 2 | 0 | +| `_docs/07-workflows-and-jobs.md` | 0 | 0 | 3 | 0 | +| `_docs/08-api-management.md` | 2 | 0 | 3 | 0 | +| `_docs/09-api-registry.md` | 1 | 0 | 0 | 0 | +| `_docs/10-api-metadata.md` | 0 | 0 | 2 | 0 | +| `_docs/11-observability.md` | 3 | 0 | 3 | 0 | +| `_docs/12-security.md` | 0 | 0 | 1 | 0 | +| `_docs/13-anti-patterns.md` | 0 | 0 | 3 | 0 | +| `_docs/14-appendix-a-protocols.md` | 4 | 0 | 4 | 0 | +| `_docs/15-appendix-b-versioning.md` | 1 | 0 | 9 | 5 | +| `_docs/16-appendix-c-websockets.md` | 5 | 0 | 2 | 0 | +| `_docs/17-appendix-d-sagas.md` | 1 | 0 | 3 | 0 | +| `_docs/18-appendix-e-errors.md` | 2 | 0 | 1 | 1 | +| `_docs/19-appendix-f-ddd-hexagonal.md` | 2 | 0 | 7 | 0 | +| `_docs/20-appendix-g-coupling.md` | 2 | 0 | 7 | 1 | +| `_docs/21-appendix-h-shutdown.md` | 1 | 0 | 4 | 0 | +| `_docs/22-appendix-i-l7-routing.md` | 4 | 0 | 10 | 1 | +| `_docs/23-appendix-j-dotnet-toolkit.md` | 10 | 0 | 6 | 1 | +| `_docs/24-appendix-k-monolith-to-microservices.md` | 5 | 0 | 8 | 3 | +| `_docs/25-appendix-l-caching.md` | 3 | 0 | 6 | 0 | +| `_docs/26-appendix-m-failure-modes.md` | 5 | 0 | 5 | 2 | +| `_docs/27-appendix-n-feature-flags.md` | 5 | 0 | 4 | 1 | +| `_docs/28-appendix-o-newman.md` | 3 | 0 | 8 | 2 | +| `_docs/29-appendix-p-dotnet-on-linux.md` | 0 | 0 | 1 | 0 | +| `_docs/29-glossary.md` | 0 | 0 | 2 | 1 | +| `_docs/31-appendix-r-minikube.md` | 0 | 0 | 19 | 19 | +| `_docs/32-appendix-s-openshift-local.md` | 0 | 0 | 20 | 20 | +| `_example_pages/index.md` | 0 | 0 | 1 | 1 | +| `_example_pages/prerequisites.md` | 0 | 0 | 6 | 5 | +| `assets/diagrams/04-cqrs.svg` | 1 | 0 | 0 | 0 | +| `assets/diagrams/04-db-per-service.svg` | 0 | 0 | 1 | 1 | +| `assets/diagrams/05-checkpoint-recovery.svg` | 1 | 0 | 0 | 0 | +| `assets/diagrams/05-sourcing-vs-streaming.svg` | 0 | 0 | 2 | 0 | +| `assets/diagrams/08-four-planes.svg` | 0 | 0 | 1 | 0 | +| `assets/diagrams/12-claim-check.svg` | 0 | 0 | 1 | 0 | +| `assets/diagrams/13-antipatterns-grid.svg` | 0 | 0 | 1 | 0 | +| `assets/diagrams/15-rest-versioning.svg` | 0 | 0 | 2 | 2 | +| `assets/diagrams/17-saga-state.svg` | 1 | 0 | 0 | 0 | +| `assets/diagrams/18-rest-errors.svg` | 0 | 0 | 2 | 1 | +| `assets/diagrams/19-strategic-ddd.svg` | 0 | 0 | 1 | 0 | +| `assets/diagrams/20-cynefin-coupling.svg` | 1 | 0 | 0 | 0 | +| `assets/diagrams/21-shutdown-sequence.svg` | 1 | 0 | 0 | 0 | +| `assets/diagrams/22-east-west.svg` | 1 | 0 | 1 | 0 | +| `assets/diagrams/22-hop-cost.svg` | 1 | 0 | 0 | 0 | +| `assets/diagrams/22-l7-layers.svg` | 0 | 0 | 2 | 0 | +| `assets/diagrams/22-sticky-sessions.svg` | 0 | 0 | 1 | 1 | +| `assets/diagrams/24-monolith-unit.svg` | 0 | 0 | 1 | 0 | +| `assets/diagrams/25-failure-taxonomy.svg` | 1 | 0 | 1 | 1 | +| `assets/diagrams/25-gray-failure.svg` | 0 | 0 | 1 | 0 | +| `assets/diagrams/25-istio-resilience.svg` | 1 | 0 | 0 | 0 | +| `assets/diagrams/27-ci-gate.svg` | 0 | 0 | 1 | 0 | +| `assets/diagrams/31-minikube-image-flow.svg` | 0 | 0 | 1 | 1 | +| `assets/diagrams/31-minikube-topology.svg` | 0 | 0 | 1 | 1 | +| `assets/diagrams/32-openshift-topology.svg` | 0 | 0 | 1 | 1 | +| `examples/02-communications/spring-boot/README.md` | 0 | 0 | 1 | 1 | +| `examples/05-event-driven/README.md` | 0 | 0 | 4 | 4 | +| `examples/05-event-driven/k8s/README.md` | 0 | 0 | 5 | 5 | +| `examples/05-event-driven/openshift/README.md` | 0 | 0 | 2 | 2 | +| `examples/12-security/cpp/README.md` | 0 | 0 | 1 | 1 | +| `examples/12-security/quarkus/README.md` | 0 | 0 | 1 | 1 | +| `examples/12-security/spring-boot/README.md` | 0 | 0 | 1 | 1 | +| `examples/16-websockets/spring-boot/README.md` | 0 | 0 | 1 | 1 | +| `examples/17-sagas/spring-boot/README.md` | 0 | 0 | 1 | 1 | +| `examples/19-ddd-hexagonal/cpp/README.md` | 0 | 0 | 1 | 0 | +| `examples/25-caching/quarkus/README.md` | 0 | 0 | 1 | 1 | +| `examples/25-caching/spring-boot/README.md` | 0 | 0 | 1 | 1 | +| `examples/PREREQUISITES.md` | 0 | 0 | 6 | 5 | +| **Total** | **95** | **0** | **239** | **109** | + +Every remaining watch hit is kept with a reason code (literal minikube/OpenShift subjects, machine-subject "silently"/"lie", result tables and table data, survey terms, identifiers, idioms such as "easy to miss", one chapter opener per chapter, misconception contrasts, and "deliberate" where it names a planned stage). + +## Batch ledgers + +### B6 (15f0f0a) appendices R-S, example pages, READMEs +- ban 0 -> 0. watch: site files ~100 -> 49, examples md 24 -> 22 (all kept, reason-coded). +- Changed: README.md:62 (internal planning notes), _example_pages/prerequisites.md:331 + examples/PREREQUISITES.md:327 heading "NOT" -> "not" (slug unchanged), examples/19-ddd-hexagonal/cpp/README.md:34 "ONLY" -> "only". +- Kept: literal-subject (minikube/OpenShift in 31, 32, prerequisites, 05 READMEs, 31/32 SVG labels); table-data (31/32 result tables, 32:35, em-dash file lists in 7 READMEs, _example_pages/index.md:66); idiom (README.md:13 and 32:118 counts; "Release It!" title); identifier (UNIQUE SQL keyword). +### B1 chapter 05 +- _docs/05: ban 16 -> 0, watch ~22 -> 11 kept (chapter-opener :10; machine-subject :243 :938; survey-term :297 :323; identifier :303; idiom :299; table-data :335; misconception-contrast :397; literal-subject :1294 :1297). +- 05-checkpoint-recovery: ban 1 -> 0 (svg + excalidraw + alt ~961). 05-sourcing-vs-streaming: watch 2 -> 0 (ALL-CAPS; svg + excalidraw). +- No heading changes. Rewrites to review: :459 "rest of this chapter" -> "which is the idea this chapter builds on"; "decisive choice"; "pays off"; "triggered by choice rather than by a crash". +### B4 appendices I-K +- 22: ban 4 lines file-level (7 incl. diagrams) -> 0, watch ~12 -> 1 kept (identifier :57 22-l7-journey). 23: ban 9 -> 0, watch 6 -> 1 kept (table-data :105 row/column count). 24: ban 7 -> 0, watch 7 -> 0. Diagrams: ban 2 -> 0, watch ~5 -> 0. +- Heading: 23 "What the .NET cloud-native story genuinely lacks" -> "... lacks" (no anchor links). +- Triples: 22-hop-cost ("pay for itself", alt + Figure I.7 caption), 22-east-west (svg+excalidraw), 22-l7-layers (svg+excalidraw+alt :43 + prose :53), 22-l7-journey alt/caption "path", 24-monolith-unit (svg+excalidraw; alt :36 em-dash split). +- Kept unflagged: 24 heading "The shared database — a deliberate intermediate state" (pre-existing, meaningful). +- Review: 23 ~166 "Simple DI", ~182 "The order matters", 24 ~354 "to keep the bytes intact". +### B2 chapters 00-13 +- Batch total: ban ~28 -> 0, watch ~40 -> 1 kept (chapter-opener 04:14). Per-file counts not tracked by the executor (validator to recount from main). +- Headings: 00 "How the rest of the book is ordered" -> "How the book is ordered"; 11 "How correlation actually works" -> "How correlation works" (no inbound anchors). +- Triples (hand-edited svg+excalidraw): 04-cqrs, 12-claim-check, 08-four-planes, 04-db-per-service, 13-antipatterns-grid (+ alt 13:22). Caption Figure 8.1 rewritten; em-dash alts in 02, 06, 12 split. +- Review: 08 "never remove one without warning" (was "never silently remove one"; human subject). +### B5 appendices L-Q and glossary (a7d0874) +- 25: ban 3->0, watch 6->0. 26: ban 4->0, watch 4->2 kept (machine-subject "lie" :37 alt, :724). 27: ban 5->0, watch 3->0. 28: ban 6->0, watch 9->2 kept (misconception-contrast :229, idiom :244). 29-P: watch 1->0. 29-glossary: watch 2->1 kept (idiom :12). 30: no hits. +- SVG: 25-istio-resilience ban 1->0; 25-gray-failure watch 1->0; 25-failure-taxonomy kept (machine-subject "lie"); 27-ci-gate watch 1->0 (generator string, svg, hand-edited excalidraw, 28 alt). +- Headings (no inbound anchors): 25 "When NOT to cache, and what to watch for" -> "When not to cache, ..."; 29-P "CIL: what `dotnet build` actually produces" -> "CIL: what `dotnet build` produces". +- Also removed the positional "Below sits" in a 26 alt. +### B3 appendices A-H (31d8eb7) +- 14: ban 4->0, watch 4->0. 15: ban 1->0, watch 11->5 (idiom :22 :25 :31 :221; machine-subject :108). 16: ban 4->0, watch 1->0. 17: ban 1->0, watch 4->0. 18: ban 2->0, watch 1->1 (machine-subject :406). 19: ban 3->0, watch 8->0. 20: ban 2->0, watch 10->1 (machine-subject :184). 21: ban 1->0, watch 4->0. SVG: ban 4->0, watch 4->3 (15-rest-versioning idiom x2; 18-rest-errors literal-subject payload data). +- Heading: 14 "Three paradigms, three mental models" -> "Three paradigms and their mental models" (no anchors). +- Triples: 17-saga-state (+ alt D.1), 18-rest-errors, 19-strategic-ddd (+ alt), 20-cynefin-coupling (+ alt), 21-shutdown-sequence. +- Review: 16 opener rewrite; 16 "Test what the backplane provides."; 18 added "so nothing about the implementation leaks"; 21 "is evidence that graceful shutdown works". + +### Repair round 1 (validator findings) +- Grammar: Appendix K "if you later need to split". Soft hype: Appendix O "powerful idea" removed. +- Scanner gaps fixed upstream (lgtm-skills #49, re-vendored at a335007): "the rest of the appendix/section" (ban), adjective "deliberate" and summary markers (watch). Five new ban hits rewritten (26 x2, 16, 20, 25-failure-taxonomy label); summary markers removed (14, 23, 26); "deliberate" rewritten except where it names a planned stage (24 heading, alt and caption; 27 "only a deliberate flip removes it"). +- Claims restored to their original strength: Appendix L caching payoff, random eviction; Appendix J "Minimal built-in DI"; chapter 05 "most consequential choice"; Appendix B "without any error"; 04-db-per-service label "quietly fails" (machine subject). +- 27-ci-gate label "in CI as they do locally"; chapter 04 positional alt "A band below" -> "A second band". +- Wrapper: `voice-scan.sh ` scans only those paths (examples/ paths as Markdown); examples output notes its relative paths. +- Heading changes across S11 (none with inbound anchors): 00, 11, 14, 23, 25, 29-P, plus the "NOT" lowercase headings in the two prerequisites pages (slug unchanged). + +Final after the recheck nits: watch 108 (the `22-sticky-sessions` label no longer says "deliberate"; the table above was generated one fix earlier and shows 109). diff --git a/assets/diagrams/04-cqrs.excalidraw b/assets/diagrams/04-cqrs.excalidraw index 93921608..adbf1a93 100644 --- a/assets/diagrams/04-cqrs.excalidraw +++ b/assets/diagrams/04-cqrs.excalidraw @@ -1435,13 +1435,13 @@ "updated": 1, "link": null, "locked": false, - "text": "write side optimises for invariants; read side for query patterns \u2014 eventually consistent, a small lag between them. Use only when the shapes genuinely differ.", + "text": "write side optimises for invariants; read side for query patterns \u2014 eventually consistent, a small lag between them. Use only when the shapes differ.", "fontSize": 12, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "write side optimises for invariants; read side for query patterns \u2014 eventually consistent, a small lag between them. Use only when the shapes genuinely differ.", + "originalText": "write side optimises for invariants; read side for query patterns \u2014 eventually consistent, a small lag between them. Use only when the shapes differ.", "lineHeight": 1.25, "baseline": 12 } diff --git a/assets/diagrams/04-cqrs.svg b/assets/diagrams/04-cqrs.svg index c2964401..33fc1f93 100644 --- a/assets/diagrams/04-cqrs.svg +++ b/assets/diagrams/04-cqrs.svg @@ -44,5 +44,5 @@ queries queries -write side optimises for invariants; read side for query patterns — eventually consistent, a small lag between them. Use only when the shapes genuinely differ. +write side optimises for invariants; read side for query patterns — eventually consistent, a small lag between them. Use only when the shapes differ. \ No newline at end of file diff --git a/assets/diagrams/05-checkpoint-recovery.excalidraw b/assets/diagrams/05-checkpoint-recovery.excalidraw index 46ac6e5e..7a2d122b 100644 --- a/assets/diagrams/05-checkpoint-recovery.excalidraw +++ b/assets/diagrams/05-checkpoint-recovery.excalidraw @@ -732,13 +732,13 @@ "updated": 1, "link": null, "locked": false, - "text": "snapshot state and input offset together; on failure restore and replay from there \u2014 and rewind on purpose to reprocess and rebuild", + "text": "snapshot state and input offset together; on failure restore and replay from there \u2014 and rewind to reprocess and rebuild", "fontSize": 10.5, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "snapshot state and input offset together; on failure restore and replay from there \u2014 and rewind on purpose to reprocess and rebuild", + "originalText": "snapshot state and input offset together; on failure restore and replay from there \u2014 and rewind to reprocess and rebuild", "lineHeight": 1.25, "baseline": 10.5 } diff --git a/assets/diagrams/05-checkpoint-recovery.svg b/assets/diagrams/05-checkpoint-recovery.svg index a4e7e4f3..36072487 100644 --- a/assets/diagrams/05-checkpoint-recovery.svg +++ b/assets/diagrams/05-checkpoint-recovery.svg @@ -24,5 +24,5 @@ resume resume -snapshot state and input offset together; on failure restore and replay from there — and rewind on purpose to reprocess and rebuild +snapshot state and input offset together; on failure restore and replay from there — and rewind to reprocess and rebuild \ No newline at end of file diff --git a/assets/diagrams/05-sourcing-vs-streaming.excalidraw b/assets/diagrams/05-sourcing-vs-streaming.excalidraw index 873a482b..33eb199b 100644 --- a/assets/diagrams/05-sourcing-vs-streaming.excalidraw +++ b/assets/diagrams/05-sourcing-vs-streaming.excalidraw @@ -56,13 +56,13 @@ "updated": 1, "link": null, "locked": false, - "text": "Event SOURCING \u2014 events ARE the state", + "text": "Event sourcing \u2014 events are the state", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "Event SOURCING \u2014 events ARE the state", + "originalText": "Event sourcing \u2014 events are the state", "lineHeight": 1.25, "baseline": 14 }, @@ -161,13 +161,13 @@ "updated": 1, "link": null, "locked": false, - "text": "the event log IS the database", + "text": "the event log is the database", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "the event log IS the database", + "originalText": "the event log is the database", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/05-sourcing-vs-streaming.svg b/assets/diagrams/05-sourcing-vs-streaming.svg index c12a901b..be80878f 100644 --- a/assets/diagrams/05-sourcing-vs-streaming.svg +++ b/assets/diagrams/05-sourcing-vs-streaming.svg @@ -1,10 +1,10 @@ -Event SOURCING — events ARE the state +Event sourcing — events are the state aggregate state = fold(events from t0) -the event log IS the database +the event log is the database every change is a new appended event current state rebuilt by replaying diff --git a/assets/diagrams/08-four-planes.excalidraw b/assets/diagrams/08-four-planes.excalidraw index 75a1fe8a..d7e02724 100644 --- a/assets/diagrams/08-four-planes.excalidraw +++ b/assets/diagrams/08-four-planes.excalidraw @@ -693,13 +693,13 @@ "updated": 1, "link": null, "locked": false, - "text": "what is actually", + "text": "what is", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "what is actually", + "originalText": "what is", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/08-four-planes.svg b/assets/diagrams/08-four-planes.svg index 5ec030eb..2d0a71e6 100644 --- a/assets/diagrams/08-four-planes.svg +++ b/assets/diagrams/08-four-planes.svg @@ -20,6 +20,6 @@ Signal plane observability · §11 -what is actually +what is happening \ No newline at end of file diff --git a/assets/diagrams/12-claim-check.excalidraw b/assets/diagrams/12-claim-check.excalidraw index 9143c448..dc9e4b13 100644 --- a/assets/diagrams/12-claim-check.excalidraw +++ b/assets/diagrams/12-claim-check.excalidraw @@ -287,13 +287,13 @@ "updated": 1, "link": null, "locked": false, - "text": "holds ONLY the token", + "text": "holds only the token", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "holds ONLY the token", + "originalText": "holds only the token", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/12-claim-check.svg b/assets/diagrams/12-claim-check.svg index 25b44581..af4f3875 100644 --- a/assets/diagrams/12-claim-check.svg +++ b/assets/diagrams/12-claim-check.svg @@ -8,7 +8,7 @@ ACLs per object · retention policy Message bus (Kafka) -holds ONLY the token +holds only the token no PII in topic history Consumer diff --git a/assets/diagrams/13-antipatterns-grid.excalidraw b/assets/diagrams/13-antipatterns-grid.excalidraw index a7c5e3a1..caf01869 100644 --- a/assets/diagrams/13-antipatterns-grid.excalidraw +++ b/assets/diagrams/13-antipatterns-grid.excalidraw @@ -623,13 +623,13 @@ "updated": 1, "link": null, "locked": false, - "text": "breaking changes ship silently", + "text": "breaking changes go unflagged", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "breaking changes ship silently", + "originalText": "breaking changes go unflagged", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/13-antipatterns-grid.svg b/assets/diagrams/13-antipatterns-grid.svg index 39809554..610a78f9 100644 --- a/assets/diagrams/13-antipatterns-grid.svg +++ b/assets/diagrams/13-antipatterns-grid.svg @@ -18,7 +18,7 @@ multiplies down to near zero No contract / no registry -breaking changes ship silently +breaking changes go unflagged and break consumers Smart pipes, dumb endpoints diff --git a/assets/diagrams/17-saga-state.excalidraw b/assets/diagrams/17-saga-state.excalidraw index 71de1c8e..5320a32d 100644 --- a/assets/diagrams/17-saga-state.excalidraw +++ b/assets/diagrams/17-saga-state.excalidraw @@ -161,13 +161,13 @@ "updated": 1, "link": null, "locked": false, - "text": "never for real sagas", + "text": "not for production", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "never for real sagas", + "originalText": "not for production", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/17-saga-state.svg b/assets/diagrams/17-saga-state.svg index ef532310..1ed5e764 100644 --- a/assets/diagrams/17-saga-state.svg +++ b/assets/diagrams/17-saga-state.svg @@ -4,7 +4,7 @@ In-memory dict / object only lost on pod restart -never for real sagas +not for production DB state machine row per saga instance diff --git a/assets/diagrams/18-rest-errors.excalidraw b/assets/diagrams/18-rest-errors.excalidraw index cb02fd81..852f751d 100644 --- a/assets/diagrams/18-rest-errors.excalidraw +++ b/assets/diagrams/18-rest-errors.excalidraw @@ -91,13 +91,13 @@ "updated": 1, "link": null, "locked": false, - "text": "fix the request; do NOT retry as-is", + "text": "fix the request; do not retry as-is", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "fix the request; do NOT retry as-is", + "originalText": "fix the request; do not retry as-is", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/18-rest-errors.svg b/assets/diagrams/18-rest-errors.svg index fa104e29..cad51ea7 100644 --- a/assets/diagrams/18-rest-errors.svg +++ b/assets/diagrams/18-rest-errors.svg @@ -2,7 +2,7 @@ 4xx — client error -fix the request; do NOT retry as-is +fix the request; do not retry as-is 429 / 503 — busy back off; honour the Retry-After header diff --git a/assets/diagrams/19-strategic-ddd.excalidraw b/assets/diagrams/19-strategic-ddd.excalidraw index df551651..49066bfb 100644 --- a/assets/diagrams/19-strategic-ddd.excalidraw +++ b/assets/diagrams/19-strategic-ddd.excalidraw @@ -329,13 +329,13 @@ "updated": 1, "link": null, "locked": false, - "text": "build it simply", + "text": "keep it simple", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "build it simply", + "originalText": "keep it simple", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/19-strategic-ddd.svg b/assets/diagrams/19-strategic-ddd.svg index 52bd7743..c4aa383f 100644 --- a/assets/diagrams/19-strategic-ddd.svg +++ b/assets/diagrams/19-strategic-ddd.svg @@ -9,7 +9,7 @@ Supporting subdomain necessary, not a differentiator -build it simply +keep it simple e.g. inventory, shipping Generic subdomain diff --git a/assets/diagrams/20-cynefin-coupling.excalidraw b/assets/diagrams/20-cynefin-coupling.excalidraw index b3c0d323..324a12ab 100644 --- a/assets/diagrams/20-cynefin-coupling.excalidraw +++ b/assets/diagrams/20-cynefin-coupling.excalidraw @@ -441,13 +441,13 @@ "updated": 1, "link": null, "locked": false, - "text": "Design boundaries deliberately", + "text": "Design boundaries", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "Design boundaries deliberately", + "originalText": "Design boundaries", "lineHeight": 1.25, "baseline": 14 }, diff --git a/assets/diagrams/20-cynefin-coupling.svg b/assets/diagrams/20-cynefin-coupling.svg index ad308602..c0e022b3 100644 --- a/assets/diagrams/20-cynefin-coupling.svg +++ b/assets/diagrams/20-cynefin-coupling.svg @@ -13,7 +13,7 @@ favour events / contracts; don't split prematurely -Design boundaries deliberately +Design boundaries DDD context mapping; invest in the right contracts diff --git a/assets/diagrams/21-shutdown-sequence.excalidraw b/assets/diagrams/21-shutdown-sequence.excalidraw index 1f7ae764..113f93e8 100644 --- a/assets/diagrams/21-shutdown-sequence.excalidraw +++ b/assets/diagrams/21-shutdown-sequence.excalidraw @@ -1016,13 +1016,13 @@ "updated": 1, "link": null, "locked": false, - "text": "liveness must stay UP throughout \u2014 or the kubelet restarts a pod that is intentionally draining", + "text": "liveness must stay UP throughout \u2014 or the kubelet restarts a pod that is draining", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "liveness must stay UP throughout \u2014 or the kubelet restarts a pod that is intentionally draining", + "originalText": "liveness must stay UP throughout \u2014 or the kubelet restarts a pod that is draining", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/21-shutdown-sequence.svg b/assets/diagrams/21-shutdown-sequence.svg index 43393493..af709c96 100644 --- a/assets/diagrams/21-shutdown-sequence.svg +++ b/assets/diagrams/21-shutdown-sequence.svg @@ -31,6 +31,6 @@ triggers triggers readiness DOWN + preStop keep new traffic off the dying pod during the async-removal race -liveness must stay UP throughout — or the kubelet restarts a pod that is intentionally draining +liveness must stay UP throughout — or the kubelet restarts a pod that is draining time → \ No newline at end of file diff --git a/assets/diagrams/22-east-west.excalidraw b/assets/diagrams/22-east-west.excalidraw index 9ec202a1..c9da59fd 100644 --- a/assets/diagrams/22-east-west.excalidraw +++ b/assets/diagrams/22-east-west.excalidraw @@ -631,13 +631,13 @@ "updated": 1, "link": null, "locked": false, - "text": "internal traffic is where most L7 decisions actually happen \u2014 and where you get mTLS, retries, and locality for free", + "text": "internal traffic is where most L7 decisions happen, and where the mesh gives you mTLS, retries, and locality", "fontSize": 12, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "internal traffic is where most L7 decisions actually happen \u2014 and where you get mTLS, retries, and locality for free", + "originalText": "internal traffic is where most L7 decisions happen, and where the mesh gives you mTLS, retries, and locality", "lineHeight": 1.25, "baseline": 12 } diff --git a/assets/diagrams/22-east-west.svg b/assets/diagrams/22-east-west.svg index 1290871d..98ac17eb 100644 --- a/assets/diagrams/22-east-west.svg +++ b/assets/diagrams/22-east-west.svg @@ -19,5 +19,5 @@ mTLS · HTTP/2 · gRPC mTLS · HTTP/2 · gRPC -internal traffic is where most L7 decisions actually happen — and where you get mTLS, retries, and locality for free +internal traffic is where most L7 decisions happen, and where the mesh gives you mTLS, retries, and locality \ No newline at end of file diff --git a/assets/diagrams/22-hop-cost.excalidraw b/assets/diagrams/22-hop-cost.excalidraw index 3b5847ed..aae10f20 100644 --- a/assets/diagrams/22-hop-cost.excalidraw +++ b/assets/diagrams/22-hop-cost.excalidraw @@ -1274,13 +1274,13 @@ "updated": 1, "link": null, "locked": false, - "text": "earn its place", + "text": "pay for itself", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "earn its place", + "originalText": "pay for itself", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/22-hop-cost.svg b/assets/diagrams/22-hop-cost.svg index 1734a70a..92d5926e 100644 --- a/assets/diagrams/22-hop-cost.svg +++ b/assets/diagrams/22-hop-cost.svg @@ -39,6 +39,6 @@ L7 on hot paths every hop must -earn its place +pay for itself the budget is finite — two layers can be cheap; four layers of overlap is where p99 goes to die \ No newline at end of file diff --git a/assets/diagrams/22-l7-layers.excalidraw b/assets/diagrams/22-l7-layers.excalidraw index b3d7e922..36f38b7e 100644 --- a/assets/diagrams/22-l7-layers.excalidraw +++ b/assets/diagrams/22-l7-layers.excalidraw @@ -1126,13 +1126,13 @@ "updated": 1, "link": null, "locked": false, - "text": "each layer makes a different kind of decision, and each emits a trace span \u2014 the journey is observable end to end", + "text": "each layer makes a distinct decision, and each emits a trace span \u2014 the path is observable end to end", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "each layer makes a different kind of decision, and each emits a trace span \u2014 the journey is observable end to end", + "originalText": "each layer makes a distinct decision, and each emits a trace span \u2014 the path is observable end to end", "lineHeight": 1.25, "baseline": 11 } diff --git a/assets/diagrams/22-l7-layers.svg b/assets/diagrams/22-l7-layers.svg index 7a056fda..9edeaf55 100644 --- a/assets/diagrams/22-l7-layers.svg +++ b/assets/diagrams/22-l7-layers.svg @@ -32,5 +32,5 @@ the mesh is the same L7 routing, applied to every internal call — not just at the edge -each layer makes a different kind of decision, and each emits a trace span — the journey is observable end to end +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/assets/diagrams/22-sticky-sessions.excalidraw b/assets/diagrams/22-sticky-sessions.excalidraw index cf45637c..c6b5bb28 100644 --- a/assets/diagrams/22-sticky-sessions.excalidraw +++ b/assets/diagrams/22-sticky-sessions.excalidraw @@ -735,13 +735,13 @@ "updated": 1, "link": null, "locked": false, - "text": "prefer stateless behind a JWT; stickiness should be a deliberate choice, not a default", + "text": "prefer stateless behind a JWT; make stickiness an explicit choice, not a default", "fontSize": 12, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "prefer stateless behind a JWT; stickiness should be a deliberate choice, not a default", + "originalText": "prefer stateless behind a JWT; make stickiness an explicit choice, not a default", "lineHeight": 1.25, "baseline": 12 } diff --git a/assets/diagrams/22-sticky-sessions.svg b/assets/diagrams/22-sticky-sessions.svg index c23a4356..98a448f3 100644 --- a/assets/diagrams/22-sticky-sessions.svg +++ b/assets/diagrams/22-sticky-sessions.svg @@ -21,5 +21,5 @@ the cost of stickiness uneven load · painful pod restarts · broken scale-to-zero · every restart re-balances all sessions -prefer stateless behind a JWT; stickiness should be a deliberate choice, not a default +prefer stateless behind a JWT; make stickiness an explicit choice, not a default \ No newline at end of file diff --git a/assets/diagrams/24-monolith-unit.excalidraw b/assets/diagrams/24-monolith-unit.excalidraw index fc38b07e..92d42d9e 100644 --- a/assets/diagrams/24-monolith-unit.excalidraw +++ b/assets/diagrams/24-monolith-unit.excalidraw @@ -1200,13 +1200,13 @@ "updated": 1, "link": null, "locked": false, - "text": "change anywhere \u2192 redeploy everything \u00b7 a bug in Reporting can take down Payments \u00b7 one scaling unit for very different workloads", + "text": "change anywhere \u2192 redeploy everything \u00b7 a bug in Reporting can take down Payments \u00b7 one scaling unit for different workloads", "fontSize": 12, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "change anywhere \u2192 redeploy everything \u00b7 a bug in Reporting can take down Payments \u00b7 one scaling unit for very different workloads", + "originalText": "change anywhere \u2192 redeploy everything \u00b7 a bug in Reporting can take down Payments \u00b7 one scaling unit for different workloads", "lineHeight": 1.25, "baseline": 12 }, diff --git a/assets/diagrams/24-monolith-unit.svg b/assets/diagrams/24-monolith-unit.svg index 2d86506f..b4070c1d 100644 --- a/assets/diagrams/24-monolith-unit.svg +++ b/assets/diagrams/24-monolith-unit.svg @@ -36,6 +36,6 @@ -change anywhere → redeploy everything · a bug in Reporting can take down Payments · one scaling unit for very different workloads +change anywhere → redeploy everything · a bug in Reporting can take down Payments · one scaling unit for different workloads not always wrong — often the simplest, fastest path to value when the system fits in one team's head \ No newline at end of file diff --git a/assets/diagrams/25-failure-taxonomy.excalidraw b/assets/diagrams/25-failure-taxonomy.excalidraw index 8f14814c..8b04acf6 100644 --- a/assets/diagrams/25-failure-taxonomy.excalidraw +++ b/assets/diagrams/25-failure-taxonomy.excalidraw @@ -518,13 +518,13 @@ "updated": 1, "link": null, "locked": false, - "text": "The defensive toolkit (the rest of this appendix)", + "text": "The defensive toolkit (patterns in this appendix)", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "The defensive toolkit (the rest of this appendix)", + "originalText": "The defensive toolkit (patterns in this appendix)", "lineHeight": 1.25, "baseline": 14 }, diff --git a/assets/diagrams/25-failure-taxonomy.svg b/assets/diagrams/25-failure-taxonomy.svg index 8290b068..5e9ecb2c 100644 --- a/assets/diagrams/25-failure-taxonomy.svg +++ b/assets/diagrams/25-failure-taxonomy.svg @@ -15,7 +15,7 @@ Distributed system -The defensive toolkit (the rest of this appendix) +The defensive toolkit (patterns in this appendix) timeouts+deadlines · retry w/ backoff+jitter · circuit breakers · bulkheads (§12) load shedding · fallback · quorum/consensus · Istio · graceful shutdown (App. H) · observability (§11) diff --git a/assets/diagrams/25-gray-failure.excalidraw b/assets/diagrams/25-gray-failure.excalidraw index 6164456b..56bbd5a0 100644 --- a/assets/diagrams/25-gray-failure.excalidraw +++ b/assets/diagrams/25-gray-failure.excalidraw @@ -126,13 +126,13 @@ "updated": 1, "link": null, "locked": false, - "text": "liveness handler trivially OK", + "text": "liveness handler returns OK", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "liveness handler trivially OK", + "originalText": "liveness handler returns OK", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/25-gray-failure.svg b/assets/diagrams/25-gray-failure.svg index a157ae8d..6cc726da 100644 --- a/assets/diagrams/25-gray-failure.svg +++ b/assets/diagrams/25-gray-failure.svg @@ -3,7 +3,7 @@ Pod slow / dropping 5% -liveness handler trivially OK +liveness handler returns OK Liveness probe GET /healthz → 200 diff --git a/assets/diagrams/25-istio-resilience.excalidraw b/assets/diagrams/25-istio-resilience.excalidraw index d4e20cba..acdde746 100644 --- a/assets/diagrams/25-istio-resilience.excalidraw +++ b/assets/diagrams/25-istio-resilience.excalidraw @@ -577,13 +577,13 @@ "updated": 1, "link": null, "locked": false, - "text": "the mesh gives timeouts, retries, mesh-bulkheads (connectionPool) and gray-failure ejection (outlierDetection) for free \u2014 don't ALSO retry in the app", + "text": "the mesh already gives timeouts, retries, mesh-bulkheads (connectionPool) and gray-failure ejection (outlierDetection) \u2014 don't also retry in the app", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "the mesh gives timeouts, retries, mesh-bulkheads (connectionPool) and gray-failure ejection (outlierDetection) for free \u2014 don't ALSO retry in the app", + "originalText": "the mesh already gives timeouts, retries, mesh-bulkheads (connectionPool) and gray-failure ejection (outlierDetection) \u2014 don't also retry in the app", "lineHeight": 1.25, "baseline": 11 } diff --git a/assets/diagrams/25-istio-resilience.svg b/assets/diagrams/25-istio-resilience.svg index c2ae3057..3df6c804 100644 --- a/assets/diagrams/25-istio-resilience.svg +++ b/assets/diagrams/25-istio-resilience.svg @@ -17,5 +17,5 @@ outlier → eject outlier → eject -the mesh gives timeouts, retries, mesh-bulkheads (connectionPool) and gray-failure ejection (outlierDetection) for free — don't ALSO retry in the app +the mesh already gives timeouts, retries, mesh-bulkheads (connectionPool) and gray-failure ejection (outlierDetection) — don't also retry in the app \ No newline at end of file diff --git a/assets/diagrams/27-ci-gate.excalidraw b/assets/diagrams/27-ci-gate.excalidraw index 79d61852..3e04a929 100644 --- a/assets/diagrams/27-ci-gate.excalidraw +++ b/assets/diagrams/27-ci-gate.excalidraw @@ -690,13 +690,13 @@ "updated": 1, "link": null, "locked": false, - "text": "the services run on Docker Engine in CI exactly as on a laptop \u2014 no managed cloud \u2014 and a failed assertion fails the build", + "text": "the services run on Docker Engine in CI as they do locally \u2014 no managed cloud \u2014 and a failed assertion fails the build", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "the services run on Docker Engine in CI exactly as on a laptop \u2014 no managed cloud \u2014 and a failed assertion fails the build", + "originalText": "the services run on Docker Engine in CI as they do locally \u2014 no managed cloud \u2014 and a failed assertion fails the build", "lineHeight": 1.25, "baseline": 11 } diff --git a/assets/diagrams/27-ci-gate.svg b/assets/diagrams/27-ci-gate.svg index c10bbf5f..32507bd8 100644 --- a/assets/diagrams/27-ci-gate.svg +++ b/assets/diagrams/27-ci-gate.svg @@ -19,5 +19,5 @@ -the services run on Docker Engine in CI exactly as on a laptop — no managed cloud — and a failed assertion fails the build +the services run on Docker Engine in CI as they do locally — no managed cloud — and a failed assertion fails the build \ No newline at end of file diff --git a/examples/19-ddd-hexagonal/cpp/README.md b/examples/19-ddd-hexagonal/cpp/README.md index 6ecbd34f..fd30195d 100644 --- a/examples/19-ddd-hexagonal/cpp/README.md +++ b/examples/19-ddd-hexagonal/cpp/README.md @@ -31,7 +31,7 @@ The verify.sh script ensures the domain layer has **zero framework imports**: - No `#include ` - No `#include ` -The domain layer depends ONLY on its own types and standard C++. Adapters +The domain layer depends only on its own types and standard C++. Adapters implement the ports (interfaces) defined by the domain. ## Running diff --git a/examples/PREREQUISITES.md b/examples/PREREQUISITES.md index c114dc66..887cf555 100644 --- a/examples/PREREQUISITES.md +++ b/examples/PREREQUISITES.md @@ -324,7 +324,7 @@ done --- -## What you do NOT need +## What you do not need All application code runs inside containers built from **Red Hat UBI** base images. You do not need to install any of these locally to run the examples: diff --git a/scripts/gen_27_diagrams.py b/scripts/gen_27_diagrams.py index 4555f7de..a523c8d4 100644 --- a/scripts/gen_27_diagrams.py +++ b/scripts/gen_27_diagrams.py @@ -23,7 +23,7 @@ ], notes=[ {"x": 460, "y": 250, "anchor": "middle", "color": "#2f6db5", - "text": "the services run on Docker Engine in CI exactly as on a laptop — no managed cloud — and a failed assertion fails the build"}, + "text": "the services run on Docker Engine in CI as they do locally — no managed cloud — and a failed assertion fails the build"}, ]) # ---- Figure O.3: one collection, six implementations ----------------------- diff --git a/scripts/voice-scan.sh b/scripts/voice-scan.sh new file mode 100755 index 00000000..03849b73 --- /dev/null +++ b/scripts/voice-scan.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# voice-scan.sh - professional-voice scan over the reader-facing surfaces of this site. +# +# scripts/voice-scan.sh gate mode: ban tier must be 0 (CI runs this) +# scripts/voice-scan.sh --all-tiers-summary all-tier totals for both scopes (never fails) +# scripts/voice-scan.sh [options] [PATH...] pass scan.sh options through, e.g. --tier watch --hits all; +# 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: +# 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) +# 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 + +START_DIR="$PWD" +cd "$(dirname "${BASH_SOURCE[0]}")/.." +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) + +# scan_scopes -- -- +# Runs each non-empty scope; returns non-zero if either run fails. +scan_scopes() { + local site=() examples=() opts=() rc=0 + while [[ $# -gt 0 && "$1" != -- ]]; do site+=("$1"); shift; done + shift + while [[ $# -gt 0 && "$1" != -- ]]; do examples+=("$1"); shift; done + shift + opts=("$@") + if [[ ${#site[@]} -gt 0 ]]; then + echo "== site" + "${SCAN[@]}" "${opts[@]}" "${site[@]}" || rc=1 + fi + if [[ ${#examples[@]} -gt 0 ]]; then + echo "== examples (Markdown only; excerpt paths are relative to examples/ when the whole tree is scanned)" + "${SCAN[@]}" --ext md "${opts[@]}" "${examples[@]}" || rc=1 + fi + return "$rc" +} + +run_both() { scan_scopes "${SITE_ROOTS[@]}" -- "${EXAMPLE_ROOTS[@]}" -- "$@"; } + +if [[ $# -eq 0 ]]; then + if run_both --tier ban --fail; then + echo "voice scan: 0 ban-tier hits" + exit 0 + fi + echo + echo "voice scan: ban-tier phrases found (rewrite them; see the lgtm-professional-voice rules):" + run_both --tier ban --hits all || true + exit 1 +fi + +if [[ "$1" == --all-tiers-summary ]]; then + run_both --tier all + exit 0 +fi + +# Split options (and their values) from paths. Options that take a value are listed explicitly. +opts=() site=() examples=() +while [[ $# -gt 0 ]]; do + case "$1" in + --tier|--hits|--ext|--exclude|--patterns) + [[ $# -ge 2 ]] || { echo "voice-scan.sh: $1 needs a value" >&2; exit 2; } + opts+=("$1" "$2"); shift 2 ;; + -*) opts+=("$1"); shift ;; + *) + # Paths are relative to where the script was run; scan them relative to the repo root. + path="$(realpath -m -- "$( [[ "$1" == /* ]] && echo "$1" || echo "$START_DIR/$1" )")" + [[ "$path" == "$ROOT" || "$path" == "$ROOT"/* ]] || { echo "voice-scan.sh: $1 is outside the repository" >&2; exit 2; } + rel="${path#"$ROOT"}"; rel="${rel#/}"; rel="${rel:-.}" + case "$rel" in + examples|examples/*) examples+=("$rel") ;; + *) site+=("$rel") ;; + esac + shift ;; + esac +done + +if [[ ${#site[@]} -eq 0 && ${#examples[@]} -eq 0 ]]; then + run_both "${opts[@]}" +else + scan_scopes "${site[@]}" -- "${examples[@]}" -- "${opts[@]}" +fi diff --git a/scripts/voice/README.md b/scripts/voice/README.md new file mode 100644 index 00000000..b0ce8380 --- /dev/null +++ b/scripts/voice/README.md @@ -0,0 +1,28 @@ +# Vendored professional-voice scanner + +`scan.sh` and `patterns.tsv` are byte-identical copies from the lgtm-skills repository. They back +`scripts/voice-scan.sh`, which CI runs in the `content-ok` check. + +| | | +|---|---| +| 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) | +| 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: + +```bash +SHA= +P=skills/lgtm-professional-voice/scripts +git -C ../lgtm-skills show "${SHA}:${P}/scan.sh" > scripts/voice/scan.sh +git -C ../lgtm-skills show "${SHA}:${P}/patterns.tsv" > scripts/voice/patterns.tsv +``` + +Then update the commit in this table. To confirm the copy still matches its source: + +```bash +for f in scan.sh patterns.tsv; do + diff <(git -C ../lgtm-skills show "${SHA}:${P}/$f") "scripts/voice/$f" && echo "$f matches" +done +``` diff --git a/scripts/voice/patterns.tsv b/scripts/voice/patterns.tsv new file mode 100644 index 00000000..a829610d --- /dev/null +++ b/scripts/voice/patterns.tsv @@ -0,0 +1,68 @@ +# lgtm-professional-voice scan patterns. +# +# Columns (TAB-separated): tier case label regex [only] +# tier ban = rewrite by default; keep only with a stated reason +# watch = triage each hit; many are legitimate in context +# case i = case-insensitive, c = case-sensitive +# only optional: comma-separated extensions the pattern applies to +# (e.g. md,markdown). Omit to scan every file type. +# regex GNU grep -E (ERE). No backreferences: scan.sh wraps each regex in +# an extra group when excerpting long lines, which shifts numbering. +# +# Rewrites for every pattern: ../references/phrase-list.md +# Project-specific jargon goes in a separate file passed with --patterns. + +# --- Confessional register (the work or the reader framed as honest/dishonest) +ban i honest / honestly / honesty \bhonest(ly|y)?\b +ban i frankly / candid(ly) / admittedly \b(frankly|candid(ly)?|admittedly)\b +ban i plainly / stated plainly \bplainly\b +ban i let's be clear / to be clear \b(let['’]?s be|to be) (clear|honest|frank|precise|explicit|fair)\b +ban i the truth is / truth be told \b(the (truth|reality) is|truth be told|in all honesty)\b +ban i not hidden / nothing hidden \b(not|nothing) (is )?hidden\b +ban i on purpose / for free \b(on purpose|for free)\b +watch i lie / lies / lying (person vs machine) \b(lie|lies|lying|liar|liars)\b + +# --- Defensive contrast (arguing against a doubt the reader never raised) +ban i genuine / genuinely \bgenuine(ly)?\b +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 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) +ban i worth noticing / worth stating \bworth (notic|noting|stat|spell|tak|hold|call|paus|seeing|emphasi|repeat|flag|dwell|point|mention|underlin|highlight)[a-z]*\b +ban i the thing to land / the takeaway \bthe (thing|point|idea|takeaway|key point|key thing|lesson) (to|you should|worth) (land|notice|take away|remember|hold onto|internali[sz]e)\b +ban i the rest of this deck/book \b(the )?rest of (this|the) (deck|book|tutorial|chapter|appendix|section|guide|course|site|workshop|talk|presentation)\b +ban i uses ... by name \b(uses|use|used|refers to|cites|catalogued|catalogs|names) [a-z ]{0,25}by name\b +watch i this section/slide covers \b(this|the next|the previous) (section|slide|chapter|part|deck|module) (is about|covers|walks|will|shows|explains|goes|turns|takes|looks)\b +watch i note that / keep in mind \b(note that|notice (that|how)|keep in mind|remember that|it['’]?s (important|worth) (to )?(note|noting|remember|mention)|as (we|you) (saw|will see|can see))\b +watch i let's dive / let's walk \blet['’]?s (dive|unpack|walk|take|look|explore|start|begin|see|break|zoom)\b + +# --- School / condescending framing +ban i capstone / homework / students \b(capstone|homework|pop quiz|lesson plan|students?|classroom)\b([^-./_]|$) +watch i simply / obviously / easy \b(simply|obviously|of course|trivially|easy|easily|don['’]?t worry|no worries|painless(ly)?|magical(ly)?|like magic|as if by magic)\b + +# --- AI-generated tells and hype +ban i load-bearing \bload[- ]bearing\b +ban i earns its place / keep \bearns? its (place|keep|complexity)\b +ban i hype vocabulary \b(delve[sd]?|delving|tapestry|game[- ]chang[a-z]*|supercharg[a-z]*|revolutioni[sz][a-z]*|cutting[- ]edge|best[- ]in[- ]class|world[- ]class|blazing(ly)?)\b +watch i soft hype \b(seamless(ly)?|unlock(s|ing)?|elevate[sd]?|empower[a-z]*|robust|powerful|leverag(e|es|ed|ing)|journey|landscape|in the realm of)\b +watch i summary marker \b(in short|in summary|to summari[sz]e|the key point)\b +watch i quietly / silently \b(quietly|silently)\b +watch i actually \bactually\b +watch i intensifiers \b(very|really|truly|incredibly|extremely|crucially|notably|importantly)\b +watch i em-dash pileup (3+ per line) —[^—]*—[^—]*— +watch i exclamation in prose [a-z)]!( |"|'|$) + +# --- Wordiness and hedging +ban i in order to / the fact that \b(in order to|the fact that|at the end of the day|it should be noted|due to the fact|for all intents and purposes)\b +watch i hedging \b(arguably|perhaps|somewhat|fairly|essentially|basically|in a sense|sort of|kind of)\b + +# --- Titles and environment leakage +watch i filename as title \b(title|eyebrow|sub|subtitle|heading)[ ]*:[ ]*["'`][^"'`]*\b[a-z0-9_.-]+\.(sh|py|js|ts|java|ya?ml|json|xml)\b +watch i filename as heading ^#{1,4} [a-z0-9_-]+\.(sh|py|java|ya?ml)\b md,markdown +watch i parallel-number title \b(two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|[0-9]+) [a-z][a-z-]{2,}, (two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|[0-9]+) [a-z][a-z-]{2,}\b +watch i environment-specific noun \b(minikube|k3d|k3s|microk8s|laptop|my machine)\b +watch c tracking code / plan path \b(DRQ|DEF|DEC|CAP|FND|ADR|REQ|RISK|TASK|STEP)-[0-9]{1,4}\b|\bPhase [A-H]\b|_plans/|\bbuild-plan\b|\bthe (roadmap|PRD)\b diff --git a/scripts/voice/scan.sh b/scripts/voice/scan.sh new file mode 100755 index 00000000..887e80ff --- /dev/null +++ b/scripts/voice/scan.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Scan a docs site, workshop, or deck source tree for voice problems. +# +# scan.sh [options] PATH... # summary (files and/or directories): hits per pattern, worst first +# scan.sh --files PATH # hits per file, worst first (for splitting a sweep) +# scan.sh --hits honest PATH # file:line excerpts for patterns whose label matches +# scan.sh --hits all --tier ban PATH +# +# Options: +# --tier ban|watch|all which tier to report (default: all) +# --hits LABEL print excerpts for patterns whose label contains LABEL ("all" = every pattern) +# --files print per-file hit totals instead of per-pattern +# --ext LIST comma-separated extensions to scan; a leading + appends to the default +# (default: md,markdown,html,adoc,txt,js,mjs,py,svg; e.g. --ext +sh,yaml +# to include demo-script narration and config comments) +# --exclude NAME prune a directory or file name (repeatable); _plans is pruned by default +# --include-plans scan _plans/ too (internal planning notes are not reader-facing) +# --patterns FILE extra TSV patterns (same format as patterns.tsv); repeatable +# --raw do not blank fenced code blocks and inline `code` in Markdown +# --fail exit 1 when any ban-tier pattern hits (pre-commit / CI gate) +# +# Read-only: the tree is never modified. Markdown is copied to a temp mirror with +# fenced code and inline code spans blanked (line numbers preserved), so commands, +# identifiers, and recorded output do not count as prose. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +pattern_files=("$here/patterns.tsv") +tier=all; hits=""; mode=summary; raw=0; fail=0 +default_exts="md,markdown,html,adoc,txt,js,mjs,py,svg" +exts="$default_exts" +excludes=(_plans) +roots=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --tier) tier="$2"; shift 2 ;; + --hits) hits="$2"; mode=hits; shift 2 ;; + --files) mode=files; shift ;; + --ext) if [[ "$2" == +* ]]; then exts="$default_exts,${2#+}"; else exts="$2"; fi; shift 2 ;; + --exclude) excludes+=("$2"); shift 2 ;; + --include-plans) excludes=("${excludes[@]/_plans}"); shift ;; + --patterns) pattern_files+=("$2"); shift 2 ;; + --raw) raw=1; shift ;; + --fail) fail=1; shift ;; + -h|--help) sed -n '2,24p' "$0" | sed 's/^# \?//'; exit 0 ;; + -*) echo "unknown option: $1" >&2; exit 2 ;; + *) roots+=("$1"); shift ;; + esac +done +[[ ${#roots[@]} -gt 0 ]] || { echo "usage: scan.sh [options] PATH... (see --help)" >&2; exit 2; } +for r in "${roots[@]}"; do [[ -e "$r" ]] || { echo "no such file or directory: $r" >&2; exit 2; }; done +case "$tier" in ban|watch|all) ;; *) echo "--tier must be ban, watch, or all" >&2; exit 2 ;; esac + +# UTF-8 so curly apostrophes and em dashes match as single characters. +if locale -a 2>/dev/null | grep -qi '^c\.utf-\?8$'; then export LC_ALL=C.UTF-8; else export LC_ALL=en_US.UTF-8; fi + +mirror="$(mktemp -d)" +# The hit list lives outside the mirror: inside it, grep would read its own output back. +out="$(mktemp)" +trap 'rm -rf "$mirror" "$out"' EXIT + +# --- Build the mirror: candidate files only, prose-only for Markdown. +find_args=() +IFS=',' read -ra ext_list <<< "$exts" +for e in "${ext_list[@]}"; do find_args+=(-o -name "*.$e"); done +find_args=("${find_args[@]:1}") +prune_args=() +for x in .git node_modules _site vendor target dist build .bundle .jekyll-cache \ + __pycache__ .venv venv .sass-cache "${excludes[@]}"; do + [[ -n "$x" ]] && prune_args+=(-o -name "$x") +done +prune_args=("${prune_args[@]:1}") + +while IFS= read -r -d '' f; do + if [[ ${#roots[@]} -eq 1 && -d "${roots[0]}" ]]; then + rel="$(realpath --relative-to="${roots[0]}" "$f")" + else + rel="$(realpath --relative-to=. "$f")"; rel="${rel#../}"; rel="${rel//..\//}" + fi + mkdir -p "$mirror/$(dirname "$rel")" + if [[ $raw -eq 0 && "$f" =~ \.(md|markdown)$ ]]; then + # Blank fenced blocks (``` / ~~~ / {% highlight %}) and inline code spans. + awk ' + /^[[:space:]]*(```|~~~)/ { infence = !infence; print ""; next } + /\{%-?[[:space:]]*highlight/ { inhl = 1; print ""; next } + /\{%-?[[:space:]]*endhighlight/ { inhl = 0; print ""; next } + infence || inhl { print ""; next } + { gsub(/`[^`]*`/, ""); print } + ' "$f" > "$mirror/$rel" + else + cp "$f" "$mirror/$rel" + fi +done < <(find "${roots[@]}" \( "${prune_args[@]}" \) -prune \ + -o -type f \( "${find_args[@]}" \) ! -name '*.min.js' ! -name 'package-lock.json' -print0) + +# --- Load patterns: tier \t case \t label \t regex +tiers=(); cases=(); labels=(); regexes=(); onlys=() +for pf in "${pattern_files[@]}"; do + [[ -f "$pf" ]] || { echo "patterns file not found: $pf" >&2; exit 2; } + while IFS=$'\t' read -r t c l r x; do + [[ -z "$t" || "$t" == \#* ]] && continue + [[ "$tier" != all && "$t" != "$tier" ]] && continue + tiers+=("$t"); cases+=("$c"); labels+=("$l"); regexes+=("$r"); onlys+=("${x:-}") + done < "$pf" +done + +grep_pat() { # grep_pat INDEX [-o] -- with -o, print a ~70-char excerpt around each hit + local i="$1" re="${regexes[$1]}" opt=() + local ci=(); [[ "${cases[$i]}" == i ]] && ci=(-i) + local inc=() # optional 5th column: comma-separated extensions the pattern applies to + if [[ -n "${onlys[$i]}" ]]; then + local e; IFS=',' read -ra _exts <<< "${onlys[$i]}" + for e in "${_exts[@]}"; do inc+=("--include=*.$e"); done + fi + if [[ "${2:-}" == -o ]]; then opt=(-o); re=".{0,70}(${re}).{0,70}"; fi + grep -rnE "${ci[@]}" "${opt[@]}" "${inc[@]}" -e "$re" "$mirror" 2>/dev/null || true +} + +ban_hits=0 +: > "$out" + +case "$mode" in + summary) + for i in "${!labels[@]}"; do + res="$(grep_pat "$i")" + [[ -z "$res" ]] && continue + n=$(printf '%s\n' "$res" | wc -l) + nf=$(printf '%s\n' "$res" | cut -d: -f1 | sort -u | wc -l) + [[ "${tiers[$i]}" == ban ]] && ban_hits=$((ban_hits + n)) + printf '%6d\t%5d\t%-5s\t%s\n' "$n" "$nf" "${tiers[$i]}" "${labels[$i]}" >> "$out" + done + printf '%6s\t%5s\t%-5s\t%s\n' LINES FILES TIER PATTERN + sort -t$'\t' -k1,1nr "$out" + total=$(awk -F'\t' '{s+=$1} END{print s+0}' "$out") + echo "--" + echo "total: $total matching lines ($ban_hits ban-tier) under ${roots[*]}" + ;; + files) + for i in "${!labels[@]}"; do + res="$(grep_pat "$i")" + [[ -z "$res" ]] && continue + printf '%s\n' "$res" | cut -d: -f1 >> "$out" + [[ "${tiers[$i]}" == ban ]] && ban_hits=$((ban_hits + $(printf '%s\n' "$res" | wc -l))) + done + printf '%6s\t%s\n' HITS FILE + sed "s|^$mirror/||" "$out" | sort | uniq -c | sort -k1,1nr | awk '{printf "%6d\t%s\n", $1, $2}' + ;; + hits) + for i in "${!labels[@]}"; do + [[ "$hits" != all && "${labels[$i],,}" != *"${hits,,}"* ]] && continue + res="$(grep_pat "$i" -o | sed "s|^$mirror/||")" + [[ -z "$res" ]] && continue + [[ "${tiers[$i]}" == ban ]] && ban_hits=$((ban_hits + $(printf '%s\n' "$res" | wc -l))) + echo "## [${tiers[$i]}] ${labels[$i]}" + printf '%s\n' "$res" | awk -F: '!seen[$1":"$2]++' + echo + done + ;; +esac + +[[ $fail -eq 1 && $ban_hits -gt 0 ]] && exit 1 +exit 0