<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://stn-dts.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://stn-dts.github.io/" rel="alternate" type="text/html" /><updated>2026-11-11T14:13:50+00:00</updated><id>https://stn-dts.github.io/feed.xml</id><title type="html">Digital Technology Solutions</title><subtitle>The Digital Technology Solutions technical blog</subtitle><entry><title type="html">Boundaries in the Build: A Modular Monolith with Effect and React Router 8</title><link href="https://stn-dts.github.io/2026/11/10/boundaries-in-the-build.html" rel="alternate" type="text/html" title="Boundaries in the Build: A Modular Monolith with Effect and React Router 8" /><published>2026-11-10T00:00:00+00:00</published><updated>2026-11-10T00:00:00+00:00</updated><id>https://stn-dts.github.io/2026/11/10/boundaries-in-the-build</id><content type="html" xml:base="https://stn-dts.github.io/2026/11/10/boundaries-in-the-build.html"><![CDATA[<p>I wanted several teams to work on one citizen-facing web application without stepping on each other. Each team owns
one area: sign-in, the citizen’s profile, benefits, jobs, payments, and a dashboard that ties them together. I also
wanted one build, one server process and one container image.</p>

<p>The usual way to keep teams apart is to put a network between them, with separate services, separate deployables and
contracts checked at run time. This time I moved the boundaries into the build instead. Each area is a package. The
compiler and a small lint script decide what each package may touch. Effect wires the pieces together on the server,
and React Router 8 renders the pages.</p>

<p>This post walks through how that works. To keep it concrete, I’ll follow one request the whole way: a signed-in citizen
opens <code class="language-plaintext highlighter-rouge">/en/jobs</code> to see job postings that match their experience.</p>

<p>One trade-off up front, so it doesn’t feel hidden later: every team ships on the same release train. If a team truly
needs to deploy on its own schedule, the modules don’t give them that. Near the end I’ll show the one narrow exception
I made, and what it costs. In exchange you get no version skew between pages, no composition layer to run, one
developer experience, and contracts the compiler checks.</p>

<h2 id="the-shape-one-app-many-packages">The shape: one app, many packages</h2>

<p>The repository has two kinds of workspace: applications and packages. One application is the product. The other is
the exception I just mentioned, and I’ll come back to it near the end.</p>

<p><img src="/assets/images/2026-11-10-modular-monolith/repo-map.svg" alt="The app at the top wires in every package; modules in the middle each publish a small API; ui and the kernel sit at the bottom" class="diagram" style="background:none;padding:0" /></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>apps/web/                  the application: one build, one process, one image
  server.ts                process entry
  server/runtime.ts        composition root: every module's Effect layer
  server/http/             the Effect HTTP server
  app/routes.ts            React Router's route config, built from the modules
  app/register.ts          types the kernel with the real app (more on this later)
  app/routes/layouts/      three layouts; the language page and the 404
apps/cra-tax-slips/        the one exception: a frontend built and deployed on its own (near the end)
packages/
  kernel/                  shared kernel: the Effect ↔ React Router bridge, request scope, module contract, fragment slot
  ui/                      the design system
  identity/                sign-in, sign-out, the session, page guards
  profile/                 the citizen's record, contact details, consent
  benefits/                the program catalogue and the benefits finder
  jobs/                    job postings matched to the citizen, and their shortlist
  dashboard/               the account's home page
  mfe-host/                mounts the exception inside the account
  mfe-contract/            the mount contract both sides share
  …                        payments, employment records, and a few more
</code></pre></div></div>

<p>The application is the <strong>composition root</strong>. It owns almost nothing: the process entry, the HTTP server, three layouts,
the 404 page, and the wiring that pulls every module in. There’s no business logic in it at all.</p>

<p>Each package under <code class="language-plaintext highlighter-rouge">packages/</code> is one <strong>bounded context</strong>, owned by one team. The <code class="language-plaintext highlighter-rouge">kernel</code> is the shared kernel in the
DDD sense: a small, slow-moving set of types and helpers every module agrees on. It depends on no other workspace
package. <code class="language-plaintext highlighter-rouge">ui</code> is the design system, and it depends only on the kernel.</p>

<p>One detail surprised people at first: <strong>modules can’t be built on their own.</strong> A module has a <code class="language-plaintext highlighter-rouge">package.json</code> for its
dependencies, its exports and its tests. It has no build, no server and no separate typecheck. The application
typechecks every module’s source and tests as one program. So a module is always checked against the <em>real</em> route
table, the <em>real</em> set of services and the <em>real</em> translations, and never against a stub that drifted.</p>

<p>A package is only a boundary if something stops other packages from reaching inside it. That’s where the module’s own
layout comes in.</p>

<h2 id="inside-a-bounded-context">Inside a bounded context</h2>

<p>Here is the jobs module, trimmed a little:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>packages/jobs/
├── package.json            exports (what the app may use) and "published" (what other modules may use)
└── src/
    ├── index.ts            module definition: translations, navigation entries
    ├── routes.ts           the route table (plain data)
    ├── routes/             React Router route modules: thin loaders/actions and page components
    ├── components/         React components (DTOs in, markup out)
    ├── features/           *.server.ts: the HTTP side; each one runs a use case
    ├── domain/             entities, value objects, domain services: plain values and functions
    ├── application/
    │   ├── ports/          outbound ports (Effect services)
    │   └── use-cases/      Effect programs
    ├── infrastructure/     adapters for the ports
    ├── api.ts              the published API: a service and its DTOs
    └── layer.server.ts     the module's Effect layer
</code></pre></div></div>

<p>The folders follow the usual DDD layering, and the rules are checked, not just suggested:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">domain/</code> is pure. It doesn’t import React, UI code or Effect services. <code class="language-plaintext highlighter-rouge">domain/match.ts</code>, for example, is a plain,
explainable scoring function: skills count most, then job title, then years of experience.</li>
  <li><code class="language-plaintext highlighter-rouge">application/</code> orchestrates the domain through ports. It never touches React or HTTP.</li>
  <li>Route modules and components never import <code class="language-plaintext highlighter-rouge">infrastructure/</code>. A loader calls a function in <code class="language-plaintext highlighter-rouge">features/</code>, and that
function runs a use case.</li>
  <li>Components get plain DTOs. No domain type ever reaches the browser.</li>
</ul>

<h3 id="the-published-api">The published API</h3>

<p>Each module decides what the rest of the world may see, in two places in its <code class="language-plaintext highlighter-rouge">package.json</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@app/jobs"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"exports"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"."</span><span class="p">:</span><span class="w"> </span><span class="s2">"./src/index.ts"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"./api"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./src/api.ts"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"./layer.server"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./src/layer.server.ts"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"./routes"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./src/routes.ts"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"./routes/*"</span><span class="p">:</span><span class="w"> </span><span class="s2">"./src/routes/*"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"app"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"kind"</span><span class="p">:</span><span class="w"> </span><span class="s2">"module"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"published"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"./api"</span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">exports</code> lists what the <em>application</em> may import: the route table, the route modules, the Effect layer. <code class="language-plaintext highlighter-rouge">published</code>
is narrower. It lists what <em>other modules</em> may import, and for jobs that’s just <code class="language-plaintext highlighter-rouge">./api</code>. Node, Vite and TypeScript all
refuse to resolve a file a package doesn’t export. A lint rule refuses any import from another module that isn’t in its
<code class="language-plaintext highlighter-rouge">published</code> list.</p>

<p><code class="language-plaintext highlighter-rouge">api.ts</code> is the module’s <strong>open host service</strong>: an Effect service plus the DTOs it speaks in.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">class</span> <span class="nx">JobMatches</span> <span class="kd">extends</span> <span class="nx">Context</span><span class="p">.</span><span class="nx">Service</span><span class="o">&lt;</span>
  <span class="nx">JobMatches</span><span class="p">,</span>
  <span class="p">{</span>
    <span class="cm">/** How many jobs near the citizen match their experience strongly (or better). */</span>
    <span class="k">readonly</span> <span class="na">strongMatchesFor</span><span class="p">:</span> <span class="p">(</span><span class="na">citizen</span><span class="p">:</span> <span class="nx">AuthUser</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">Effect</span><span class="o">&lt;</span><span class="kr">number</span><span class="o">&gt;</span><span class="p">;</span>
  <span class="p">}</span>
<span class="o">&gt;</span><span class="p">()(</span><span class="dl">'</span><span class="s1">@app/jobs/JobMatches</span><span class="dl">'</span><span class="p">)</span> <span class="p">{}</span>
</code></pre></div></div>

<h3 id="two-ways-to-depend-on-another-context">Two ways to depend on another context</h3>

<p>Modules call each other in two different ways, on purpose:</p>

<p><img src="/assets/images/2026-11-10-modular-monolith/context-map.svg" alt="Context map: the dashboard consumes profile, benefits and jobs directly; jobs reaches profile and employment records through anti-corruption layers" class="diagram" style="background:none;padding:0" /></p>

<ul>
  <li><strong>Customer/supplier.</strong> The dashboard owns no data. It asks profile who the citizen is, asks benefits which programs
might help, and asks jobs how many postings match. It uses each supplier’s language as-is, because all it does is
display the answers.</li>
  <li><strong>Anti-corruption layer.</strong> Jobs needs to know about the citizen too, but it thinks in terms of a <em>candidate</em> with
skills and years of experience, not a <em>citizen record</em>. So jobs defines its own port in its own language, and exactly
one adapter translates profile’s API into it. We’ll see that adapter shortly.</li>
</ul>

<p>Either way, a call between modules is a plain function call inside one process. There’s no network hop, no
serialization and no API version to keep compatible at run time. The contract is still real, though. It’s just
enforced at build time and in code review instead of on the wire.</p>

<h3 id="who-enforces-all-this">Who enforces all this</h3>

<p>A single script, <code class="language-plaintext highlighter-rouge">check-boundaries.ts</code>, runs as part of <code class="language-plaintext highlighter-rouge">lint</code>. It checks things a reviewer shouldn’t have to spot by
eye:</p>

<ul>
  <li><strong>Direction.</strong> Nothing depends on the application. The kernel depends on nothing, and <code class="language-plaintext highlighter-rouge">ui</code> depends only on the
kernel. The module graph has no cycles.</li>
  <li><strong>Published APIs only.</strong> A module may import another module only through an entry listed in <code class="language-plaintext highlighter-rouge">published</code>.</li>
  <li><strong>Declared dependencies.</strong> If a module imports another workspace package at run time, that package must be in its
<code class="language-plaintext highlighter-rouge">dependencies</code>, so <code class="language-plaintext highlighter-rouge">package.json</code> always shows the real module graph.</li>
  <li><strong>No reaching out.</strong> A relative import never leaves its package.</li>
  <li><strong>Layering</strong> inside each module, as described above.</li>
  <li><strong>Owned infrastructure.</strong> Only the identity module may use the OIDC client or Redis, and only identity may make
outbound network calls. Every other module reaches the outside world through a port.</li>
  <li><strong>No dynamic code.</strong> An <code class="language-plaintext highlighter-rouge">import()</code> of anything but a string literal is allowed in exactly one file, and you’ll meet
it near the end.</li>
  <li><strong>Other applications</strong> may depend on shared packages only, never on a module.</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">CODEOWNERS</code> covers the social side. Each module, and each application, belongs to one team. The contract files (<code class="language-plaintext highlighter-rouge">api.ts</code> and <code class="language-plaintext highlighter-rouge">package.json</code>)
belong to an architecture group, so adding an edge to the module graph is a reviewed decision. The boundaries script
even checks that <code class="language-plaintext highlighter-rouge">CODEOWNERS</code> keeps up: every workspace has an owner, and every contract file is owned by the
architecture group alone.</p>

<p>What the script can’t see is GitHub’s side of that file. If a team named in <code class="language-plaintext highlighter-rouge">CODEOWNERS</code> doesn’t exist, or can’t write
to the repository, GitHub quietly ignores it. The files it was meant to guard then have no code owner at all, and anyone
with write access can approve them. So the teams are code too. A small Terraform module, applied with Terragrunt,
manages every team <code class="language-plaintext highlighter-rouge">CODEOWNERS</code> names, who is in each one, and each team’s access to the repository.</p>

<p>The list of teams lives in one file, <code class="language-plaintext highlighter-rouge">.github/code-owner-teams.json</code>. The boundaries script checks <code class="language-plaintext highlighter-rouge">CODEOWNERS</code> against
it, and the Terragrunt stack reads the same file and passes it to the module, which refuses to plan unless the two
match. Here’s that check, simplified a little:</p>

<div class="language-terraform highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">validation</span> <span class="p">{</span>
  <span class="nx">condition</span>     <span class="p">=</span> <span class="nx">toset</span><span class="p">(</span><span class="nx">keys</span><span class="p">(</span><span class="kd">var</span><span class="p">.</span><span class="nx">teams</span><span class="p">))</span> <span class="p">==</span> <span class="nx">toset</span><span class="p">(</span><span class="kd">var</span><span class="p">.</span><span class="nx">required_team_slugs</span><span class="p">)</span>
  <span class="nx">error_message</span> <span class="p">=</span> <span class="s2">"The managed teams must be exactly required_team_slugs (the CODEOWNERS team registry)."</span>
<span class="p">}</span>
</code></pre></div></div>

<p>So adding a team is one pull request that touches three files: the registry, <code class="language-plaintext highlighter-rouge">CODEOWNERS</code> and the stack. Lint checks
that the first two agree, and the plan checks the third. The stack is applied <em>before</em> the pull request merges, so
<code class="language-plaintext highlighter-rouge">CODEOWNERS</code> never names a team that doesn’t exist yet.</p>

<p>A few details make the teams as hard to drift as the code:</p>

<ul>
  <li><strong>Membership is authoritative.</strong> The stack lists who is in each team. Anyone added through GitHub’s settings page is
removed on the next apply. Joining a team is a pull request against <code class="language-plaintext highlighter-rouge">infra/</code>, which the platform team owns, so it
gets reviewed like any other change.</li>
  <li><strong>Every team gets write access.</strong> <code class="language-plaintext highlighter-rouge">CODEOWNERS</code> ignores an owner that can’t push, so the stack grants it explicitly.</li>
  <li><strong>An empty team fails to plan.</strong> An authoritative, empty member list would empty the team on GitHub, and quietly
leave its files without an owner.</li>
  <li><strong>Every name has a project prefix</strong>, like <code class="language-plaintext highlighter-rouge">msca-jobs</code> or <code class="language-plaintext highlighter-rouge">msca-architecture</code>, so a team can’t be mistaken for
another project’s team in a shared organization.</li>
</ul>

<p>Two limits, stated plainly. The state is a local file for now, outside the worktree, with no locking between machines.
One person applies, and anyone else rebuilds the state by importing what’s already on GitHub. A shared backend comes
before production. And the stack doesn’t cover branch protection, the setting that turns a code owner’s review into a
requirement.</p>

<p>That covers the layout. Inside each module, though, the interesting part is how the layers connect: the domain is
plain code, and everything else reaches the world through ports. That’s where Effect comes in.</p>

<h2 id="ports-and-adapters-wired-by-effect">Ports and adapters, wired by Effect</h2>

<p>If you haven’t used <a href="https://effect.website">Effect</a>, you need one idea for the rest of this post. An
<code class="language-plaintext highlighter-rouge">Effect&lt;A, E, R&gt;</code> is a description of a program that succeeds with an <code class="language-plaintext highlighter-rouge">A</code>, can fail with a typed error <code class="language-plaintext highlighter-rouge">E</code>, and needs
the services <code class="language-plaintext highlighter-rouge">R</code> before it can run. That third parameter, <code class="language-plaintext highlighter-rouge">R</code>, is the one that matters here.</p>

<h3 id="a-port-is-a-service">A port is a service</h3>

<p>A port is a <code class="language-plaintext highlighter-rouge">Context.Service</code>: a typed key plus the interface behind it.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/** Outbound port: the jobs on offer. */</span>
<span class="k">export</span> <span class="kd">class</span> <span class="nx">JobBoard</span> <span class="kd">extends</span> <span class="nx">Context</span><span class="p">.</span><span class="nx">Service</span><span class="o">&lt;</span>
  <span class="nx">JobBoard</span><span class="p">,</span>
  <span class="p">{</span>
    <span class="k">readonly</span> <span class="na">postings</span><span class="p">:</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">Effect</span><span class="o">&lt;</span><span class="k">readonly</span> <span class="nx">JobPosting</span><span class="p">[]</span><span class="o">&gt;</span><span class="p">;</span>
    <span class="k">readonly</span> <span class="na">find</span><span class="p">:</span> <span class="p">(</span><span class="na">jobId</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">Effect</span><span class="o">&lt;</span><span class="nx">Option</span><span class="p">.</span><span class="nx">Option</span><span class="o">&lt;</span><span class="nx">JobPosting</span><span class="o">&gt;&gt;</span><span class="p">;</span>
  <span class="p">}</span>
<span class="o">&gt;</span><span class="p">()(</span><span class="dl">'</span><span class="s1">@app/jobs/JobBoard</span><span class="dl">'</span><span class="p">)</span> <span class="p">{}</span>
</code></pre></div></div>

<p>There’s no decorator, no reflection and no container to configure. It’s a class you can <code class="language-plaintext highlighter-rouge">yield*</code> from inside an Effect
program.</p>

<h3 id="a-use-case-declares-its-ports-in-its-type">A use case declares its ports in its type</h3>

<p>Here is the use case behind <code class="language-plaintext highlighter-rouge">/en/jobs</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">SearchJobs</span> <span class="o">=</span> <span class="p">(</span><span class="nx">request</span><span class="p">:</span> <span class="nx">SearchRequest</span><span class="p">)</span> <span class="o">=&gt;</span>
  <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">CurrentUser</span><span class="p">;</span>
    <span class="kd">const</span> <span class="nx">candidate</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">candidateWithSkills</span><span class="p">(</span><span class="nx">user</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">shortlist</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="p">(</span><span class="k">yield</span><span class="o">*</span> <span class="nx">Shortlists</span><span class="p">).</span><span class="nx">find</span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">subject</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">postings</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="p">(</span><span class="k">yield</span><span class="o">*</span> <span class="nx">JobBoard</span><span class="p">).</span><span class="nx">postings</span><span class="p">;</span>
    <span class="kd">const</span> <span class="nx">location</span> <span class="o">=</span> <span class="nx">request</span><span class="p">.</span><span class="nx">location</span> <span class="o">??</span> <span class="s2">`</span><span class="p">${</span><span class="nx">candidate</span><span class="p">.</span><span class="nx">city</span><span class="p">}</span><span class="s2">, </span><span class="p">${</span><span class="nx">candidate</span><span class="p">.</span><span class="nx">province</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>

    <span class="c1">// Pure domain code: filter, score, sort.</span>
    <span class="kd">const</span> <span class="nx">ranked</span> <span class="o">=</span> <span class="nx">rank</span><span class="p">(</span><span class="nx">postings</span><span class="p">,</span> <span class="nx">candidate</span><span class="p">,</span> <span class="nx">shortlist</span><span class="p">,</span> <span class="p">{</span> <span class="p">...</span><span class="nx">request</span><span class="p">,</span> <span class="nx">location</span> <span class="p">});</span>

    <span class="k">return</span> <span class="p">{</span> <span class="nx">candidate</span><span class="p">,</span> <span class="nx">location</span><span class="p">,</span> <span class="na">results</span><span class="p">:</span> <span class="nx">paginate</span><span class="p">(</span><span class="nx">ranked</span><span class="p">,</span> <span class="nx">request</span><span class="p">.</span><span class="nx">page</span> <span class="o">??</span> <span class="mi">1</span><span class="p">)</span> <span class="cm">/* … */</span> <span class="p">};</span>
  <span class="p">}).</span><span class="nx">pipe</span><span class="p">(</span><span class="nx">Effect</span><span class="p">.</span><span class="nx">withSpan</span><span class="p">(</span><span class="dl">'</span><span class="s1">SearchJobs</span><span class="dl">'</span><span class="p">));</span>
</code></pre></div></div>

<p>You don’t write its type by hand. TypeScript infers it:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">Effect</span><span class="o">&lt;</span><span class="nx">SearchResult</span><span class="p">,</span> <span class="nx">never</span><span class="p">,</span> <span class="nx">CurrentUser</span> <span class="o">|</span> <span class="nx">CandidateProfiles</span> <span class="o">|</span> <span class="nx">SkillProfiles</span> <span class="o">|</span> <span class="nx">Shortlists</span> <span class="o">|</span> <span class="nx">JobBoard</span><span class="o">&gt;</span>
</code></pre></div></div>

<p>That last parameter is the list of ports this use case needs, and the compiler tracks it for you. If you come from
Spring, it’s a lot like constructor injection, except the wiring is checked at compile time and not when the context
starts. If you add a <code class="language-plaintext highlighter-rouge">yield* SomeNewPort</code> and nothing ever provides <code class="language-plaintext highlighter-rouge">SomeNewPort</code>, the build fails.</p>

<h3 id="adapters-are-layers">Adapters are layers</h3>

<p>An adapter implements a port and is packaged as a <code class="language-plaintext highlighter-rouge">Layer</code>. The in-memory job board is about as simple as one gets:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">layer</span> <span class="o">=</span> <span class="nx">Layer</span><span class="p">.</span><span class="nx">effect</span><span class="p">(</span>
  <span class="nx">JobBoard</span><span class="p">,</span>
  <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">now</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">Clock</span><span class="p">.</span><span class="nx">currentTimeMillis</span><span class="p">;</span>
    <span class="kd">const</span> <span class="nx">postings</span> <span class="o">=</span> <span class="nx">SAMPLES</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="cm">/* date each sample relative to now */</span><span class="p">);</span>
    <span class="k">return</span> <span class="nx">JobBoard</span><span class="p">.</span><span class="k">of</span><span class="p">({</span>
      <span class="na">postings</span><span class="p">:</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">succeed</span><span class="p">(</span><span class="nx">postings</span><span class="p">),</span>
      <span class="na">find</span><span class="p">:</span> <span class="p">(</span><span class="nx">jobId</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">succeed</span><span class="p">(</span><span class="nx">Option</span><span class="p">.</span><span class="nx">fromNullishOr</span><span class="p">(</span><span class="nx">postings</span><span class="p">.</span><span class="nx">find</span><span class="p">((</span><span class="nx">p</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">p</span><span class="p">.</span><span class="nx">id</span> <span class="o">===</span> <span class="nx">jobId</span><span class="p">))),</span>
    <span class="p">});</span>
  <span class="p">}),</span>
<span class="p">);</span>
</code></pre></div></div>

<p>The anti-corruption layer is also just an adapter. Jobs has its own port, <code class="language-plaintext highlighter-rouge">CandidateProfiles</code>, and this adapter
implements it on top of profile’s published <code class="language-plaintext highlighter-rouge">CitizenDirectory</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/**
 * Profile's language (a citizen) becomes ours (a candidate with skills and
 * experience) here, and only here: if profile's API changes, this file
 * changes; the jobs domain doesn't.
 */</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">layer</span> <span class="o">=</span> <span class="nx">Layer</span><span class="p">.</span><span class="nx">effect</span><span class="p">(</span>
  <span class="nx">CandidateProfiles</span><span class="p">,</span>
  <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">directory</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">CitizenDirectory</span><span class="p">;</span> <span class="c1">// profile's published API</span>
    <span class="kd">const</span> <span class="nx">work</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">WorkExperience</span><span class="p">;</span>        <span class="c1">// another of jobs' own ports</span>
    <span class="k">return</span> <span class="nx">CandidateProfiles</span><span class="p">.</span><span class="k">of</span><span class="p">({</span>
      <span class="na">candidateFor</span><span class="p">:</span> <span class="p">(</span><span class="nx">citizen</span><span class="p">)</span> <span class="o">=&gt;</span>
        <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
          <span class="kd">const</span> <span class="nx">summary</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">directory</span><span class="p">.</span><span class="nx">summaryOf</span><span class="p">(</span><span class="nx">citizen</span><span class="p">);</span>
          <span class="kd">const</span> <span class="nx">experience</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">work</span><span class="p">.</span><span class="nx">experienceOf</span><span class="p">(</span><span class="nx">citizen</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
            <span class="nx">Effect</span><span class="p">.</span><span class="nx">catchTag</span><span class="p">(</span><span class="dl">'</span><span class="s1">ExperienceUnavailable</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span>
              <span class="nx">Effect</span><span class="p">.</span><span class="nx">logWarning</span><span class="p">(</span><span class="dl">'</span><span class="s1">employment records unavailable; matching without experience</span><span class="dl">'</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
                <span class="nx">Effect</span><span class="p">.</span><span class="k">as</span><span class="p">(</span><span class="dl">'</span><span class="s1">unavailable</span><span class="dl">'</span> <span class="k">as</span> <span class="kd">const</span><span class="p">),</span>
              <span class="p">),</span>
            <span class="p">),</span>
          <span class="p">);</span>
          <span class="k">return</span> <span class="nx">toCandidate</span><span class="p">(</span><span class="nx">summary</span><span class="p">,</span> <span class="nx">experience</span><span class="p">);</span>
        <span class="p">}),</span>
    <span class="p">});</span>
  <span class="p">}),</span>
<span class="p">);</span>
</code></pre></div></div>

<p>In the whole jobs module, this is the only file that knows profile’s API exists. It also makes a domain decision at the
seam: if employment records are down, jobs still answers, with a candidate flagged as having unknown experience.</p>

<p>Put together, the module looks like a textbook hexagon:</p>

<p><img src="/assets/images/2026-11-10-modular-monolith/hexagon.svg" alt="The jobs module as a hexagon: domain at the centre, use cases and ports around it, route modules driving it from the left, adapters implementing ports on the right" class="diagram" style="background:none;padding:0" /></p>

<h3 id="tests-swap-a-port-in-one-line">Tests swap a port in one line</h3>

<p>Because a port is just a key in <code class="language-plaintext highlighter-rouge">R</code>, a test provides whatever it likes for it. Here’s the anti-corruption layer tested
against a stand-in for profile and a failing employment-records port:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">it</span><span class="p">.</span><span class="nx">effect</span><span class="p">(</span><span class="dl">'</span><span class="s1">degrades to a candidate flagged as having unreadable experience</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span>
  <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">candidate</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="p">(</span><span class="k">yield</span><span class="o">*</span> <span class="nx">CandidateProfiles</span><span class="p">).</span><span class="nx">candidateFor</span><span class="p">(</span><span class="nx">USER</span><span class="p">);</span>
    <span class="nx">assert</span><span class="p">.</span><span class="nx">strictEqual</span><span class="p">(</span><span class="nx">candidate</span><span class="p">.</span><span class="nx">experienceUnavailable</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
    <span class="nx">assert</span><span class="p">.</span><span class="nx">deepStrictEqual</span><span class="p">(</span><span class="nx">candidate</span><span class="p">.</span><span class="nx">skills</span><span class="p">,</span> <span class="p">[]);</span>
  <span class="p">}).</span><span class="nx">pipe</span><span class="p">(</span>
    <span class="nx">Effect</span><span class="p">.</span><span class="nx">provide</span><span class="p">(</span>
      <span class="nx">ProfileCandidates</span><span class="p">.</span><span class="nx">layer</span><span class="p">.</span><span class="nx">pipe</span><span class="p">(</span>
        <span class="nx">Layer</span><span class="p">.</span><span class="nx">provide</span><span class="p">(</span>
          <span class="nx">Layer</span><span class="p">.</span><span class="nx">mergeAll</span><span class="p">(</span>
            <span class="nx">Layer</span><span class="p">.</span><span class="nx">succeed</span><span class="p">(</span><span class="nx">CitizenDirectory</span><span class="p">,</span> <span class="nx">CitizenDirectory</span><span class="p">.</span><span class="k">of</span><span class="p">({</span>
              <span class="na">summaryOf</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">succeed</span><span class="p">({</span> <span class="na">givenName</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Sam</span><span class="dl">'</span><span class="p">,</span> <span class="na">city</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Ottawa</span><span class="dl">'</span><span class="p">,</span> <span class="na">province</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ON</span><span class="dl">'</span> <span class="p">}),</span>
            <span class="p">})),</span>
            <span class="nx">Layer</span><span class="p">.</span><span class="nx">succeed</span><span class="p">(</span><span class="nx">WorkExperience</span><span class="p">,</span> <span class="nx">WorkExperience</span><span class="p">.</span><span class="k">of</span><span class="p">({</span>
              <span class="na">experienceOf</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">fail</span><span class="p">(</span><span class="k">new</span> <span class="nx">ExperienceUnavailable</span><span class="p">()),</span>
            <span class="p">})),</span>
          <span class="p">),</span>
        <span class="p">),</span>
      <span class="p">),</span>
    <span class="p">),</span>
  <span class="p">),</span>
<span class="p">);</span>
</code></pre></div></div>

<p>There are no mocking libraries and no monkey-patching. The test builds a smaller graph of layers than production does.</p>

<p>So each module ends up handing back one <code class="language-plaintext highlighter-rouge">Layer</code>. Something has to put all of those together.</p>

<h2 id="the-composition-root">The composition root</h2>

<p>A module’s <code class="language-plaintext highlighter-rouge">layer.server.ts</code> merges its adapters with the services it publishes:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">Adapters</span> <span class="o">=</span> <span class="nx">Layer</span><span class="p">.</span><span class="nx">mergeAll</span><span class="p">(</span>
  <span class="nx">InMemoryJobBoard</span><span class="p">.</span><span class="nx">layer</span><span class="p">,</span>
  <span class="nx">InMemoryShortlists</span><span class="p">.</span><span class="nx">layer</span><span class="p">,</span>
  <span class="nx">InMemoryApplyStores</span><span class="p">.</span><span class="nx">layer</span><span class="p">,</span>
  <span class="nx">ProfileCandidates</span><span class="p">.</span><span class="nx">layer</span><span class="p">.</span><span class="nx">pipe</span><span class="p">(</span><span class="nx">Layer</span><span class="p">.</span><span class="nx">provide</span><span class="p">(</span><span class="nx">EmploymentExperience</span><span class="p">.</span><span class="nx">layer</span><span class="p">)),</span>
<span class="p">);</span>

<span class="cm">/** Requires profile's CitizenDirectory and employment-records' EmploymentRecords (the application provides them). */</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">layer</span> <span class="o">=</span> <span class="nx">Layer</span><span class="p">.</span><span class="nx">merge</span><span class="p">(</span><span class="nx">JobMatchesService</span><span class="p">.</span><span class="nx">layer</span><span class="p">,</span> <span class="nx">JobMatchesService</span><span class="p">.</span><span class="nx">activityLayer</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
  <span class="nx">Layer</span><span class="p">.</span><span class="nx">provideMerge</span><span class="p">(</span><span class="nx">Adapters</span><span class="p">),</span>
<span class="p">);</span>
</code></pre></div></div>

<p>The published service has one small trick worth showing. <code class="language-plaintext highlighter-rouge">JobMatches</code> is built on top of jobs’ own use cases, which
need jobs’ private ports. I don’t want those ports showing up in the <em>dashboard’s</em> <code class="language-plaintext highlighter-rouge">R</code> just because the dashboard calls
<code class="language-plaintext highlighter-rouge">JobMatches</code>. So the service captures its dependencies when the layer is built, and provides them itself:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">layer</span> <span class="o">=</span> <span class="nx">Layer</span><span class="p">.</span><span class="nx">effect</span><span class="p">(</span>
  <span class="nx">JobMatches</span><span class="p">,</span>
  <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">services</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">context</span><span class="o">&lt;</span><span class="nx">CandidateProfiles</span> <span class="o">|</span> <span class="nx">JobBoard</span> <span class="o">|</span> <span class="nx">Shortlists</span> <span class="o">|</span> <span class="nx">SkillProfiles</span><span class="o">&gt;</span><span class="p">();</span>
    <span class="k">return</span> <span class="nx">JobMatches</span><span class="p">.</span><span class="k">of</span><span class="p">({</span>
      <span class="na">strongMatchesFor</span><span class="p">:</span> <span class="p">(</span><span class="nx">citizen</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">countMatches</span><span class="p">(</span><span class="nx">citizen</span><span class="p">,</span> <span class="dl">'</span><span class="s1">strong</span><span class="dl">'</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span><span class="nx">Effect</span><span class="p">.</span><span class="nx">provideContext</span><span class="p">(</span><span class="nx">services</span><span class="p">)),</span>
    <span class="p">});</span>
  <span class="p">}),</span>
<span class="p">);</span>
</code></pre></div></div>

<p>From the outside, <code class="language-plaintext highlighter-rouge">strongMatchesFor</code> returns an <code class="language-plaintext highlighter-rouge">Effect&lt;number&gt;</code> that needs nothing. Jobs’ internals stay internal,
even at the type level.</p>

<h3 id="five-tiers">Five tiers</h3>

<p>The application’s <code class="language-plaintext highlighter-rouge">server/runtime.ts</code> stacks every module’s layer in tiers that mirror the module graph:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/** Modules whose layers need no other module's services. */</span>
<span class="kd">const</span> <span class="nx">Independent</span> <span class="o">=</span> <span class="nx">Layer</span><span class="p">.</span><span class="nx">mergeAll</span><span class="p">(</span><span class="nx">Identity</span><span class="p">.</span><span class="nx">layer</span><span class="p">,</span> <span class="nx">Benefits</span><span class="p">.</span><span class="nx">layer</span><span class="p">,</span> <span class="nx">EmploymentRecords</span><span class="p">.</span><span class="nx">layer</span><span class="p">,</span> <span class="nx">Payments</span><span class="p">.</span><span class="nx">layer</span><span class="p">);</span>

<span class="cm">/** Profile reads employment records. The island host reads identity, and manifests through the app's HTTP adapter. */</span>
<span class="kd">const</span> <span class="nx">Profiled</span> <span class="o">=</span> <span class="nx">Layer</span><span class="p">.</span><span class="nx">mergeAll</span><span class="p">(</span><span class="nx">Profile</span><span class="p">.</span><span class="nx">layer</span><span class="p">,</span> <span class="nx">MfeHost</span><span class="p">.</span><span class="nx">layer</span><span class="p">.</span><span class="nx">pipe</span><span class="p">(</span><span class="nx">Layer</span><span class="p">.</span><span class="nx">provide</span><span class="p">(</span><span class="nx">HttpManifestSource</span><span class="p">.</span><span class="nx">layer</span><span class="p">))).</span><span class="nx">pipe</span><span class="p">(</span>
  <span class="nx">Layer</span><span class="p">.</span><span class="nx">provideMerge</span><span class="p">(</span><span class="nx">Independent</span><span class="p">),</span>
<span class="p">);</span>

<span class="cm">/** Jobs reads profile and employment records; employment insurance reads employment records. */</span>
<span class="kd">const</span> <span class="nx">Services</span> <span class="o">=</span> <span class="nx">Layer</span><span class="p">.</span><span class="nx">mergeAll</span><span class="p">(</span><span class="nx">Jobs</span><span class="p">.</span><span class="nx">layer</span><span class="p">,</span> <span class="nx">EmploymentInsurance</span><span class="p">.</span><span class="nx">layer</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span><span class="nx">Layer</span><span class="p">.</span><span class="nx">provideMerge</span><span class="p">(</span><span class="nx">Profiled</span><span class="p">));</span>

<span class="cm">/** Life events derive from what every service above publishes. */</span>
<span class="kd">const</span> <span class="nx">Events</span> <span class="o">=</span> <span class="nx">LifeEvents</span><span class="p">.</span><span class="nx">layer</span><span class="p">.</span><span class="nx">pipe</span><span class="p">(</span><span class="nx">Layer</span><span class="p">.</span><span class="nx">provideMerge</span><span class="p">(</span><span class="nx">Services</span><span class="p">));</span>

<span class="cm">/** The dashboard is the sink: it composes the layers above, and nothing depends on it. */</span>
<span class="kd">const</span> <span class="nx">Modules</span> <span class="o">=</span> <span class="nx">Dashboard</span><span class="p">.</span><span class="nx">layer</span><span class="p">.</span><span class="nx">pipe</span><span class="p">(</span><span class="nx">Layer</span><span class="p">.</span><span class="nx">provideMerge</span><span class="p">(</span><span class="nx">Events</span><span class="p">));</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">AppLayer</span> <span class="o">=</span> <span class="nx">Demo</span><span class="p">.</span><span class="nx">pipe</span><span class="p">(</span><span class="nx">Layer</span><span class="p">.</span><span class="nx">provideMerge</span><span class="p">(</span><span class="nx">Modules</span><span class="p">));</span>
</code></pre></div></div>

<p><img src="/assets/images/2026-11-10-modular-monolith/layer-tiers.svg" alt="Module layers stacked in five tiers, from independent modules at the bottom to the dashboard at the top, inside the server layer and NodeRuntime.runMain" class="diagram" style="background:none;padding:0" /></p>

<p>Two properties fall out of this for free.</p>

<p><strong>A missing service is a compile error.</strong> The server only accepts a <code class="language-plaintext highlighter-rouge">Layer</code> whose remaining requirements are <code class="language-plaintext highlighter-rouge">never</code>.
If jobs starts needing a service that no tier provides, <code class="language-plaintext highlighter-rouge">AppLayer</code> won’t typecheck.</p>

<p><strong>Bad configuration stops the process at boot.</strong> Layers are built once, eagerly, when the process starts. Settings are
read with Effect’s <code class="language-plaintext highlighter-rouge">Config</code>. A missing setting, a session secret that’s too short, or an identity provider that doesn’t
answer means the layer fails to build, and the process exits before it accepts a single request.</p>

<p>Choosing an adapter from configuration is also just a layer:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">stores</span> <span class="o">=</span> <span class="p">{</span> <span class="na">memory</span><span class="p">:</span> <span class="nx">InMemorySessionStore</span><span class="p">.</span><span class="nx">layer</span><span class="p">,</span> <span class="na">redis</span><span class="p">:</span> <span class="nx">RedisSessionStore</span><span class="p">.</span><span class="nx">layer</span> <span class="p">}</span> <span class="k">as</span> <span class="kd">const</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">layer</span> <span class="o">=</span> <span class="nx">Layer</span><span class="p">.</span><span class="nx">unwrap</span><span class="p">(</span>
  <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">Layer</span><span class="p">.</span><span class="nx">merge</span><span class="p">(</span><span class="nx">stores</span><span class="p">[</span><span class="k">yield</span><span class="o">*</span> <span class="nx">StoreKind</span><span class="p">],</span> <span class="nx">Session</span><span class="p">.</span><span class="nx">secretLayer</span><span class="p">);</span> <span class="c1">// SESSION_STORE=memory|redis</span>
  <span class="p">}),</span>
<span class="p">);</span>
</code></pre></div></div>

<p>At this point <code class="language-plaintext highlighter-rouge">AppLayer</code> describes the whole application. Next, something has to serve it.</p>

<h2 id="the-server-is-a-layer-too">The server is a Layer too</h2>

<p>The process entry is one line:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">runServer</span><span class="p">({</span> <span class="na">layer</span><span class="p">:</span> <span class="nx">AppLayer</span><span class="p">,</span> <span class="na">wrap</span><span class="p">:</span> <span class="p">(</span><span class="nx">handler</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">withSession</span><span class="p">(</span><span class="nx">handler</span><span class="p">)</span> <span class="p">});</span>
</code></pre></div></div>

<p>and <code class="language-plaintext highlighter-rouge">runServer</code> is hardly longer:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">runServer</span> <span class="o">=</span> <span class="o">&lt;</span><span class="nx">A</span><span class="p">,</span> <span class="nx">E</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">options</span><span class="p">:</span> <span class="nx">ServerOptions</span><span class="o">&lt;</span><span class="nx">A</span><span class="p">,</span> <span class="nx">E</span><span class="o">&gt;</span><span class="p">):</span> <span class="k">void</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">NodeRuntime</span><span class="p">.</span><span class="nx">runMain</span><span class="p">(</span><span class="nx">Layer</span><span class="p">.</span><span class="nx">launch</span><span class="p">(</span><span class="nx">makeServerLayer</span><span class="p">(</span><span class="nx">options</span><span class="p">)),</span> <span class="p">{</span> <span class="nx">teardown</span> <span class="p">});</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The HTTP server itself is a <code class="language-plaintext highlighter-rouge">Layer</code>, built with Effect’s platform HTTP modules. <code class="language-plaintext highlighter-rouge">NodeRuntime.runMain</code> owns the process:
it handles <code class="language-plaintext highlighter-rouge">SIGTERM</code> and <code class="language-plaintext highlighter-rouge">SIGINT</code>, interrupts the program, and runs every finalizer in reverse order. The custom
<code class="language-plaintext highlighter-rouge">teardown</code> makes a graceful stop exit with code <code class="language-plaintext highlighter-rouge">0</code>. By default an interrupt exits with <code class="language-plaintext highlighter-rouge">130</code>, which an orchestrator
reads as a crash.</p>

<p>Every request passes through a small middleware stack, outermost first:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">middleware</span> <span class="o">=</span> <span class="nx">HttpMiddleware</span><span class="p">.</span><span class="nx">make</span><span class="p">((</span><span class="nx">app</span><span class="p">)</span> <span class="o">=&gt;</span>
  <span class="nx">Tracing</span><span class="p">.</span><span class="nx">middleware</span><span class="p">(</span><span class="nx">RequestId</span><span class="p">.</span><span class="nx">middleware</span><span class="p">(</span><span class="nx">HttpMiddleware</span><span class="p">.</span><span class="nx">logger</span><span class="p">(</span><span class="nx">app</span><span class="p">))),</span>
<span class="p">);</span>
</code></pre></div></div>

<ul>
  <li><strong>Tracing</strong> opens the HTTP server span and continues an incoming <code class="language-plaintext highlighter-rouge">traceparent</code>, using OpenTelemetry under the hood.</li>
  <li><strong>RequestId</strong> takes a well-formed <code class="language-plaintext highlighter-rouge">x-request-id</code> header or generates one. It adds the ID to every log line and to the
span, and provides it as a <code class="language-plaintext highlighter-rouge">CurrentRequest</code> service. It echoes the header from a pre-response handler, so the header
is set even on error responses.</li>
  <li><strong>The logger</strong> writes the access log.</li>
</ul>

<p>A few more details from the server layer:</p>

<ul>
  <li><strong>It only listens once it can answer.</strong> The server loads the SSR bundle (or starts Vite) <em>before</em> it opens the port.
If it accepted a connection while it was still loading, that request would hang, and so would a readiness probe.</li>
  <li><strong>In production</strong>, hashed assets under <code class="language-plaintext highlighter-rouge">/assets/</code> are served with a one-year immutable cache. Anything that isn’t a
file falls through to React Router.</li>
  <li><strong>In development</strong>, Vite runs in middleware mode as a scoped resource (<code class="language-plaintext highlighter-rouge">Effect.acquireRelease</code>), so it closes when the
program’s scope closes. Vite registers its own signal handlers that call <code class="language-plaintext highlighter-rouge">process.exit()</code>. The server removes them so
that <code class="language-plaintext highlighter-rouge">runMain</code> stays the only thing in charge of shutdown.</li>
  <li><strong>There’s no server build step.</strong> <code class="language-plaintext highlighter-rouge">server.ts</code> runs directly under Node’s built-in TypeScript type stripping.</li>
</ul>

<h3 id="the-session-is-an-effect-wrapper">The session is an Effect wrapper</h3>

<p><code class="language-plaintext highlighter-rouge">withSession</code> wraps the React Router handler. It reads a signed <code class="language-plaintext highlighter-rouge">__Host-</code> cookie, loads the session from the store, and
keeps the request’s session state in a <code class="language-plaintext highlighter-rouge">Ref</code>. It provides a <code class="language-plaintext highlighter-rouge">CurrentSession</code> service to everything inside. It also
registers a pre-response handler that saves the session and sets or clears the cookie, so even an error response gets
the cookie right. A session that’s never written is never stored. Tokens from the identity provider never reach the
browser. (The island near the end gets a narrower, short-lived token of its own.)</p>

<p>By the time a request reaches the end of this chain, it carries a full Effect context: the application’s services, the
server span, the log annotations, <code class="language-plaintext highlighter-rouge">CurrentRequest</code> and <code class="language-plaintext highlighter-rouge">CurrentSession</code>. Next it goes to React Router, which knows
nothing about Effect.</p>

<h2 id="react-router-8-owns-the-page">React Router 8 owns the page</h2>

<p>React Router runs in framework mode with server rendering on. Its job is everything a page needs: matching URLs,
layouts, loaders and actions, rendering, and client-side navigation.</p>

<h3 id="route-config-built-from-packages">Route config built from packages</h3>

<p>Each module declares its pages as plain data:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">routes</span> <span class="o">=</span> <span class="nx">defineModuleRoutes</span><span class="p">({</span>
  <span class="na">module</span><span class="p">:</span> <span class="dl">'</span><span class="s1">jobs</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">idPrefix</span><span class="p">:</span> <span class="dl">'</span><span class="s1">JOB</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">routes</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">account</span><span class="p">:</span> <span class="p">[</span>
      <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">JOB-0001</span><span class="dl">'</span><span class="p">,</span> <span class="na">file</span><span class="p">:</span> <span class="dl">'</span><span class="s1">@app/jobs/routes/search.tsx</span><span class="dl">'</span><span class="p">,</span> <span class="na">paths</span><span class="p">:</span> <span class="p">{</span> <span class="na">en</span><span class="p">:</span> <span class="dl">'</span><span class="s1">/en/jobs</span><span class="dl">'</span><span class="p">,</span> <span class="na">fr</span><span class="p">:</span> <span class="dl">'</span><span class="s1">/fr/emplois</span><span class="dl">'</span> <span class="p">}</span> <span class="p">},</span>
      <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">JOB-0002</span><span class="dl">'</span><span class="p">,</span> <span class="na">file</span><span class="p">:</span> <span class="dl">'</span><span class="s1">@app/jobs/routes/job.tsx</span><span class="dl">'</span><span class="p">,</span> <span class="na">paths</span><span class="p">:</span> <span class="p">{</span> <span class="na">en</span><span class="p">:</span> <span class="dl">'</span><span class="s1">/en/jobs/:jobId</span><span class="dl">'</span><span class="p">,</span> <span class="na">fr</span><span class="p">:</span> <span class="dl">'</span><span class="s1">/fr/emplois/:jobId</span><span class="dl">'</span> <span class="p">}</span> <span class="p">},</span>
      <span class="c1">// …</span>
    <span class="p">],</span>
  <span class="p">},</span>
<span class="p">});</span>
</code></pre></div></div>

<p>A few things to notice:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">file</code> is a package specifier</strong>, not a path. While React Router loads its route config, the application resolves
each one with <code class="language-plaintext highlighter-rouge">require.resolve</code>, through the module’s package <code class="language-plaintext highlighter-rouge">exports</code>. So a module decides which of its files are
route modules, and a page can’t point into another module’s internals.</li>
  <li><strong>Every page has an English and a French path.</strong> The route table comes from our
<a href="/2025/09/05/i18n-rr7.html">i18n routing library</a>. It fails at startup if an ID is duplicated or a
translation is missing. The URL is the <em>only</em> source of the language. We never look at a cookie or the
<code class="language-plaintext highlighter-rouge">Accept-Language</code> header, and a lint rule bans the helper that would.</li>
  <li><strong>Pages are grouped by area</strong>: <code class="language-plaintext highlighter-rouge">public</code>, <code class="language-plaintext highlighter-rouge">onboarding</code> or <code class="language-plaintext highlighter-rouge">account</code>. The area decides which layout wraps the page.</li>
</ul>

<p>The application’s <code class="language-plaintext highlighter-rouge">routes.ts</code> puts everything together:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="k">default</span> <span class="p">[</span>
  <span class="nx">index</span><span class="p">(</span><span class="dl">'</span><span class="s1">routes/language-selection.tsx</span><span class="dl">'</span><span class="p">),</span>
  <span class="p">...</span><span class="nx">untranslatedRoutes</span><span class="p">(</span><span class="nx">routeTables</span><span class="p">).</span><span class="nx">map</span><span class="p">(({</span> <span class="nx">id</span><span class="p">,</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">file</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="nx">route</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="nx">toAppFile</span><span class="p">(</span><span class="nx">file</span><span class="p">),</span> <span class="p">{</span> <span class="nx">id</span> <span class="p">})),</span>
  <span class="p">...</span><span class="nx">toRouteConfigEntries</span><span class="p">(</span><span class="nx">resolveRouteFiles</span><span class="p">(</span><span class="nx">i18nRoutes</span><span class="p">)),</span>
  <span class="nx">route</span><span class="p">(</span><span class="dl">'</span><span class="s1">*</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">routes/not-found.tsx</span><span class="dl">'</span><span class="p">),</span>
<span class="p">]</span> <span class="nx">satisfies</span> <span class="nx">RouteConfig</span><span class="p">;</span>
</code></pre></div></div>

<h3 id="layouts-and-middleware-guards">Layouts and middleware guards</h3>

<p><code class="language-plaintext highlighter-rouge">/en/jobs</code> is an <code class="language-plaintext highlighter-rouge">account</code> page, so it renders inside the account layout. That layout uses React Router’s middleware to
guard every page under it:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">middleware</span><span class="p">:</span> <span class="nx">Route</span><span class="p">.</span><span class="nx">MiddlewareFunction</span><span class="p">[]</span> <span class="o">=</span> <span class="p">[</span><span class="nx">requireSignedIn</span><span class="p">,</span> <span class="nx">requireConsent</span><span class="p">];</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">loader</span> <span class="o">=</span> <span class="p">({</span> <span class="nx">context</span> <span class="p">}:</span> <span class="nx">Route</span><span class="p">.</span><span class="nx">LoaderArgs</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="na">user</span><span class="p">:</span> <span class="nx">signedInUser</span><span class="p">(</span><span class="nx">context</span><span class="p">)</span> <span class="p">});</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">requireSignedIn</code> comes from the identity module, and <code class="language-plaintext highlighter-rouge">requireConsent</code> comes from profile. The layout doesn’t know how
either one works. A signed-out request gets a document redirect to sign in, because signing in leaves the application
for the identity provider, and a client-side navigation can’t follow it there. A signed-in request gets the user
recorded in the router context. The layout also renders the side navigation, which every module contributes entries to
through its <code class="language-plaintext highlighter-rouge">defineModule</code> definition.</p>

<h3 id="thin-route-modules">Thin route modules</h3>

<p>The jobs page’s route module barely does anything:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">loader</span> <span class="o">=</span> <span class="p">({</span> <span class="nx">context</span><span class="p">,</span> <span class="nx">request</span> <span class="p">}:</span> <span class="nx">LoaderFunctionArgs</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">loadJobs</span><span class="p">(</span><span class="nx">context</span><span class="p">,</span> <span class="nx">request</span><span class="p">);</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">action</span> <span class="o">=</span> <span class="p">({</span> <span class="nx">context</span><span class="p">,</span> <span class="nx">request</span> <span class="p">}:</span> <span class="nx">ActionFunctionArgs</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">changeShortlist</span><span class="p">(</span><span class="nx">context</span><span class="p">,</span> <span class="nx">request</span><span class="p">);</span>

<span class="k">export</span> <span class="k">default</span> <span class="kd">function</span> <span class="nx">JobSearchPage</span><span class="p">()</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">{</span> <span class="nx">results</span><span class="p">,</span> <span class="nx">total</span><span class="p">,</span> <span class="nx">query</span> <span class="cm">/* … */</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">useLoaderData</span><span class="o">&lt;</span><span class="k">typeof</span> <span class="nx">loader</span><span class="o">&gt;</span><span class="p">();</span>
  <span class="c1">// …render</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">useLoaderData&lt;typeof loader&gt;()</code> gives the component the exact DTO type the loader returns. The search itself is a
plain <code class="language-plaintext highlighter-rouge">GET</code> form, so its state lives in the URL. It works without JavaScript, and you can bookmark a search. Moving
between modules (from jobs to benefits, say) is a client-side navigation inside one React tree and one router. Our
end-to-end tests check that crossing every module in the navigation never reloads the document.</p>

<p>That leaves two unanswered questions in that loader. What exactly is <code class="language-plaintext highlighter-rouge">context</code>? And how does <code class="language-plaintext highlighter-rouge">loadJobs</code> reach
<code class="language-plaintext highlighter-rouge">SearchJobs</code>, which needs five services that React Router has never heard of?</p>

<h2 id="the-bridge">The bridge</h2>

<p>The answer is a small piece of the kernel that joins the two runtimes. Here’s the whole trip for our request:</p>

<p><img src="/assets/images/2026-11-10-modular-monolith/request-lifecycle.svg" alt="Sequence diagram of one request for /en/jobs, from the Effect HTTP server through React Router to the jobs module and its adapters, and back" class="diagram" style="background:none;padding:0" /></p>

<h3 id="from-effect-to-react-router">From Effect to React Router</h3>

<p>At the end of the middleware chain, the Effect server hands the request to React Router’s request handler. Before it
does, it captures the request’s <strong>entire Effect context</strong>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">makeReactRouterHandler</span> <span class="o">=</span> <span class="p">(</span><span class="nx">loadBundle</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">SsrBundle</span><span class="o">&gt;</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">handleRequest</span> <span class="o">=</span> <span class="nx">createRequestHandler</span><span class="p">(</span><span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">(</span><span class="k">await</span> <span class="nx">loadBundle</span><span class="p">()).</span><span class="nx">build</span><span class="p">,</span> <span class="nx">mode</span><span class="p">);</span>

  <span class="k">return</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
    <span class="c1">// This request's services, server span, log annotations, CurrentRequest, CurrentSession…</span>
    <span class="kd">const</span> <span class="nx">context</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">context</span><span class="p">();</span>

    <span class="k">return</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">HttpEffect</span><span class="p">.</span><span class="nx">fromWebHandler</span><span class="p">(</span><span class="k">async</span> <span class="p">(</span><span class="nx">request</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">bundle</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">loadBundle</span><span class="p">();</span>
      <span class="k">return</span> <span class="nx">handleRequest</span><span class="p">(</span><span class="nx">request</span><span class="p">,</span> <span class="nx">bundle</span><span class="p">.</span><span class="nx">createAppLoadContext</span><span class="p">({</span> <span class="nx">context</span><span class="p">,</span> <span class="na">signal</span><span class="p">:</span> <span class="nx">request</span><span class="p">.</span><span class="nx">signal</span> <span class="p">}));</span>
    <span class="p">});</span>
  <span class="p">});</span>
<span class="p">};</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">createAppLoadContext</code> stores that context, together with the request’s abort signal, in React Router’s
<code class="language-plaintext highlighter-rouge">RouterContextProvider</code> under a typed key:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">requestScopeKey</span> <span class="o">=</span> <span class="nx">createContext</span><span class="o">&lt;</span><span class="nx">RequestScope</span><span class="o">&gt;</span><span class="p">();</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">createAppLoadContext</span> <span class="o">=</span> <span class="p">(</span><span class="nx">scope</span><span class="p">:</span> <span class="nx">RequestScope</span><span class="p">):</span> <span class="nx">RouterContextProvider</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">context</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">RouterContextProvider</span><span class="p">();</span>
  <span class="nx">context</span><span class="p">.</span><span class="kd">set</span><span class="p">(</span><span class="nx">requestScopeKey</span><span class="p">,</span> <span class="nx">scope</span><span class="p">);</span>
  <span class="k">return</span> <span class="nx">context</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div></div>

<p>One gotcha here cost me an afternoon. React Router context keys are compared by identity. The key that <em>sets</em> the scope
and the key that loaders <em>read</em> it with must be the same object. That only holds if both come from the same module
graph. So <code class="language-plaintext highlighter-rouge">createAppLoadContext</code> is re-exported from the SSR bundle’s entry, next to React Router’s server build, and
the server always takes both from the bundle.</p>

<h3 id="from-react-router-back-to-effect-run">From React Router back to Effect: <code class="language-plaintext highlighter-rouge">run()</code></h3>

<p>Loaders, actions and middleware all call one function to get back into Effect. Here it is, simplified a little:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">run</span><span class="o">&lt;</span><span class="nx">A</span><span class="p">,</span> <span class="nx">E</span> <span class="kd">extends</span> <span class="nx">Tagged</span><span class="p">,</span> <span class="nx">H</span> <span class="kd">extends</span> <span class="nx">ErrorHandlers</span><span class="o">&lt;</span><span class="nx">E</span><span class="o">&gt;&gt;</span><span class="p">(</span>
  <span class="nx">context</span><span class="p">:</span> <span class="nb">Readonly</span><span class="o">&lt;</span><span class="nb">Pick</span><span class="o">&lt;</span><span class="nx">RouterContextProvider</span><span class="p">,</span> <span class="dl">'</span><span class="s1">get</span><span class="dl">'</span><span class="o">&gt;&gt;</span><span class="p">,</span>
  <span class="nx">effect</span><span class="p">:</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">Effect</span><span class="o">&lt;</span><span class="nx">A</span><span class="p">,</span> <span class="nx">E</span><span class="p">,</span> <span class="nx">BridgeServices</span><span class="o">&gt;</span><span class="p">,</span>
  <span class="nx">options</span><span class="p">:</span> <span class="p">{</span> <span class="k">readonly</span> <span class="nx">span</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="k">readonly</span> <span class="nx">onError</span><span class="p">:</span> <span class="nx">H</span> <span class="p">},</span>
<span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">A</span> <span class="o">|</span> <span class="nx">ReturnType</span><span class="o">&lt;</span><span class="nx">H</span><span class="p">[</span><span class="kr">keyof</span> <span class="nx">H</span><span class="p">]</span><span class="o">&gt;&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">scope</span> <span class="o">=</span> <span class="nx">requestScope</span><span class="p">(</span><span class="nx">context</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="nx">getAuthUser</span><span class="p">(</span><span class="nx">context</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">services</span> <span class="o">=</span> <span class="nx">user</span> <span class="o">===</span> <span class="kc">undefined</span> <span class="p">?</span> <span class="nx">scope</span><span class="p">.</span><span class="nx">context</span> <span class="p">:</span> <span class="nx">Context</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="nx">scope</span><span class="p">.</span><span class="nx">context</span><span class="p">,</span> <span class="nx">CurrentUser</span><span class="p">,</span> <span class="nx">user</span><span class="p">);</span>

  <span class="kd">const</span> <span class="nx">exit</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">runPromiseExitWith</span><span class="p">(</span><span class="nx">services</span><span class="p">)(</span>
    <span class="nx">effect</span><span class="p">.</span><span class="nx">pipe</span><span class="p">(</span><span class="nx">Effect</span><span class="p">.</span><span class="nx">tapCause</span><span class="p">((</span><span class="nx">cause</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">logOutcome</span><span class="p">(</span><span class="nx">options</span><span class="p">.</span><span class="nx">span</span><span class="p">,</span> <span class="nx">cause</span><span class="p">)),</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">withSpan</span><span class="p">(</span><span class="nx">options</span><span class="p">.</span><span class="nx">span</span><span class="p">)),</span>
    <span class="p">{</span> <span class="na">signal</span><span class="p">:</span> <span class="nx">scope</span><span class="p">.</span><span class="nx">signal</span> <span class="p">},</span>
  <span class="p">);</span>

  <span class="k">if</span> <span class="p">(</span><span class="nx">Exit</span><span class="p">.</span><span class="nx">isSuccess</span><span class="p">(</span><span class="nx">exit</span><span class="p">))</span> <span class="k">return</span> <span class="nx">exit</span><span class="p">.</span><span class="nx">value</span><span class="p">;</span>          <span class="c1">// a plain DTO (or a redirect)</span>
  <span class="kd">const</span> <span class="nx">failure</span> <span class="o">=</span> <span class="nx">Cause</span><span class="p">.</span><span class="nx">findError</span><span class="p">(</span><span class="nx">exit</span><span class="p">.</span><span class="nx">cause</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">failure</span><span class="p">.</span><span class="nx">_tag</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">Failure</span><span class="dl">'</span><span class="p">)</span> <span class="k">throw</span> <span class="nx">Cause</span><span class="p">.</span><span class="nx">squash</span><span class="p">(</span><span class="nx">exit</span><span class="p">.</span><span class="nx">cause</span><span class="p">);</span> <span class="c1">// defect or interruption: a 500</span>
  <span class="k">return</span> <span class="nx">options</span><span class="p">.</span><span class="nx">onError</span><span class="p">[</span><span class="nx">failure</span><span class="p">.</span><span class="nx">success</span><span class="p">.</span><span class="nx">_tag</span><span class="p">](</span><span class="nx">failure</span><span class="p">.</span><span class="nx">success</span><span class="p">);</span>  <span class="c1">// a typed failure: its handler</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It does a few jobs in a small space:</p>

<ul>
  <li><strong>It runs the effect inside the request’s own context.</strong> The use case can <code class="language-plaintext highlighter-rouge">yield*</code> any application service, and its
spans become children of the HTTP server span. Its log lines carry the request ID.</li>
  <li><strong>It adds <code class="language-plaintext highlighter-rouge">CurrentUser</code>.</strong> The account layout’s guard recorded the signed-in user in the router context, and <code class="language-plaintext highlighter-rouge">run()</code>
turns that into a <code class="language-plaintext highlighter-rouge">CurrentUser</code> service. That’s why <code class="language-plaintext highlighter-rouge">SearchJobs</code> can simply <code class="language-plaintext highlighter-rouge">yield* CurrentUser</code>. A module never sees
the session, the tokens or the identity provider, only a plain <code class="language-plaintext highlighter-rouge">AuthUser</code>.</li>
  <li><strong>It follows the request’s lifetime.</strong> If the browser goes away, the abort signal interrupts the running effect. That
gets logged at info level, not as an error.</li>
  <li><strong>It keeps three kinds of failure apart.</strong> Expected failures are logged at debug level and mapped to a response.
Defects are logged as errors and become a 500 through the route’s error boundary. Interruptions are just logged.</li>
</ul>

<p>So here’s <code class="language-plaintext highlighter-rouge">loadJobs</code>, the function the jobs loader calls:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">loadJobs</span> <span class="o">=</span> <span class="p">(</span><span class="nx">context</span><span class="p">:</span> <span class="nx">Context</span><span class="p">,</span> <span class="nx">request</span><span class="p">:</span> <span class="nx">Request</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">params</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">url</span><span class="p">).</span><span class="nx">searchParams</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">query</span> <span class="o">=</span> <span class="p">(</span><span class="nx">params</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">q</span><span class="dl">'</span><span class="p">)</span> <span class="o">??</span> <span class="dl">''</span><span class="p">).</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">100</span><span class="p">);</span>
  <span class="c1">// …parse the rest of the search from the URL</span>

  <span class="k">return</span> <span class="nx">run</span><span class="p">(</span>
    <span class="nx">context</span><span class="p">,</span>
    <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">found</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">SearchJobs</span><span class="p">({</span> <span class="nx">query</span><span class="p">,</span> <span class="nx">location</span><span class="p">,</span> <span class="nx">savedOnly</span><span class="p">,</span> <span class="nx">province</span><span class="p">,</span> <span class="nx">sort</span><span class="p">,</span> <span class="nx">page</span> <span class="p">});</span>
      <span class="k">return</span> <span class="p">{</span>
        <span class="nx">query</span><span class="p">,</span>
        <span class="na">total</span><span class="p">:</span> <span class="nx">found</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">total</span><span class="p">,</span>
        <span class="na">results</span><span class="p">:</span> <span class="nx">found</span><span class="p">.</span><span class="nx">results</span><span class="p">.</span><span class="nx">items</span><span class="p">.</span><span class="nx">map</span><span class="p">(({</span> <span class="nx">posting</span><span class="p">,</span> <span class="nx">score</span><span class="p">,</span> <span class="nx">rating</span><span class="p">,</span> <span class="nx">saved</span><span class="p">,</span> <span class="nx">top</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">({</span>
          <span class="na">id</span><span class="p">:</span> <span class="nx">posting</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span>
          <span class="na">title</span><span class="p">:</span> <span class="nx">posting</span><span class="p">.</span><span class="nx">title</span><span class="p">,</span>
          <span class="na">employer</span><span class="p">:</span> <span class="nx">posting</span><span class="p">.</span><span class="nx">employer</span><span class="p">,</span>
          <span class="nx">score</span><span class="p">,</span>
          <span class="nx">rating</span><span class="p">,</span>
          <span class="c1">// …a plain DTO, nothing from the domain</span>
        <span class="p">})),</span>
      <span class="p">};</span>
    <span class="p">}),</span>
    <span class="p">{</span> <span class="na">span</span><span class="p">:</span> <span class="dl">'</span><span class="s1">jobs.search</span><span class="dl">'</span><span class="p">,</span> <span class="na">onError</span><span class="p">:</span> <span class="p">{}</span> <span class="p">},</span>
  <span class="p">);</span>
<span class="p">};</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">onError: {}</code> is not a shortcut. It’s a statement that <code class="language-plaintext highlighter-rouge">SearchJobs</code> can’t fail in an expected way, and the compiler
holds us to it. <code class="language-plaintext highlighter-rouge">ErrorHandlers</code> is a mapped type with one required handler per error tag:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">type</span> <span class="nx">ErrorHandlers</span><span class="o">&lt;</span><span class="nx">E</span> <span class="kd">extends</span> <span class="p">{</span> <span class="k">readonly</span> <span class="na">_tag</span><span class="p">:</span> <span class="kr">string</span> <span class="p">}</span><span class="o">&gt;</span> <span class="o">=</span> <span class="p">{</span>
  <span class="k">readonly</span> <span class="p">[</span><span class="nx">K</span> <span class="k">in</span> <span class="nx">E</span><span class="p">[</span><span class="dl">'</span><span class="s1">_tag</span><span class="dl">'</span><span class="p">]]:</span> <span class="p">(</span><span class="na">error</span><span class="p">:</span> <span class="nx">Extract</span><span class="o">&lt;</span><span class="nx">E</span><span class="p">,</span> <span class="p">{</span> <span class="k">readonly</span> <span class="na">_tag</span><span class="p">:</span> <span class="nx">K</span> <span class="p">}</span><span class="o">&gt;</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">ReturnType</span><span class="o">&lt;</span><span class="k">typeof</span> <span class="nx">data</span><span class="o">&gt;</span> <span class="o">|</span> <span class="nx">Response</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div></div>

<p>If a use case gains a new failure, every call site that doesn’t handle it stops compiling.</p>

<h3 id="a-second-request-when-things-can-fail">A second request: when things can fail</h3>

<p><code class="language-plaintext highlighter-rouge">/en/jobs</code> never fails in an interesting way, so let’s look at a different request for a moment. A citizen edits their
phone number on their profile, and the form posts back. The <code class="language-plaintext highlighter-rouge">ChangeDetail</code> use case can fail in two ways. The domain can
reject the value (<code class="language-plaintext highlighter-rouge">DetailRejected</code>), or someone may have changed the profile in another tab since this form was opened
(<code class="language-plaintext highlighter-rouge">RevisionConflict</code>, an optimistic-concurrency check).</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="nx">run</span><span class="p">(</span><span class="nx">context</span><span class="p">,</span> <span class="nx">ChangeDetail</span><span class="p">(</span><span class="nx">field</span><span class="p">,</span> <span class="nx">value</span><span class="p">,</span> <span class="nx">version</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span><span class="nx">Effect</span><span class="p">.</span><span class="nx">map</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nx">redirect</span><span class="p">(</span><span class="nx">pageWithout</span><span class="p">(</span><span class="nx">request</span><span class="p">,</span> <span class="dl">'</span><span class="s1">edit</span><span class="dl">'</span><span class="p">)))),</span> <span class="p">{</span>
  <span class="na">span</span><span class="p">:</span> <span class="dl">'</span><span class="s1">profile.details.change</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">onError</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">DetailRejected</span><span class="p">:</span> <span class="p">({</span> <span class="nx">problem</span> <span class="p">})</span> <span class="o">=&gt;</span>
      <span class="nx">data</span><span class="p">({</span> <span class="na">fieldError</span><span class="p">:</span> <span class="p">{</span> <span class="nx">field</span><span class="p">,</span> <span class="nx">problem</span><span class="p">,</span> <span class="nx">value</span> <span class="p">},</span> <span class="na">conflict</span><span class="p">:</span> <span class="kc">false</span> <span class="p">},</span> <span class="p">{</span> <span class="na">status</span><span class="p">:</span> <span class="mi">400</span> <span class="p">}),</span>
    <span class="c1">// Someone (another tab, another device) changed the profile since this form was opened.</span>
    <span class="na">RevisionConflict</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">data</span><span class="p">({</span> <span class="na">fieldError</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span> <span class="na">conflict</span><span class="p">:</span> <span class="kc">true</span> <span class="p">},</span> <span class="p">{</span> <span class="na">status</span><span class="p">:</span> <span class="mi">409</span> <span class="p">}),</span>
  <span class="p">},</span>
<span class="p">});</span>
</code></pre></div></div>

<p>The use case knows nothing about HTTP status codes. The route module knows nothing about how a phone number is
validated. The bridge is the one place where a typed domain failure becomes a 400 or a 409, and the compiler won’t let
anyone forget a case.</p>

<h3 id="how-the-kernel-learns-about-the-app">How the kernel learns about the app</h3>

<p>That leaves one puzzle. <code class="language-plaintext highlighter-rouge">run()</code> lives in the kernel, and the kernel depends on nothing. Yet <code class="language-plaintext highlighter-rouge">run()</code> rejects an effect
that needs a service no module provides. How does the kernel know which services exist?</p>

<p>It doesn’t. The application tells it, through TypeScript’s declaration merging:</p>

<p><img src="/assets/images/2026-11-10-modular-monolith/register-inversion.svg" alt="The kernel declares an empty Register interface; the app fills it in; every module is then typechecked against the real app" class="diagram" style="background:none;padding:0" /></p>

<p>The kernel declares an empty interface and derives its types from whatever ends up in it:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kr">interface</span> <span class="nx">Register</span> <span class="p">{}</span>

<span class="k">export</span> <span class="kd">type</span> <span class="nx">BridgeServices</span> <span class="o">=</span> <span class="nx">Register</span> <span class="kd">extends</span> <span class="p">{</span> <span class="k">readonly</span> <span class="na">services</span><span class="p">:</span> <span class="nx">infer</span> <span class="nx">S</span> <span class="p">}</span>
  <span class="p">?</span> <span class="nx">S</span> <span class="o">|</span> <span class="nx">CurrentRequest</span> <span class="o">|</span> <span class="nx">CurrentUser</span>
  <span class="p">:</span> <span class="kr">any</span><span class="p">;</span>
</code></pre></div></div>

<p>The application fills it in from <code class="language-plaintext highlighter-rouge">app/register.ts</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">declare</span> <span class="kr">module</span> <span class="dl">'</span><span class="s1">@app/kernel/bridge.server</span><span class="dl">'</span> <span class="p">{</span>
  <span class="kr">interface</span> <span class="nx">Register</span> <span class="p">{</span>
    <span class="c1">// Every module's services, plus the per-request session.</span>
    <span class="k">readonly</span> <span class="nx">services</span><span class="p">:</span> <span class="nx">Layer</span><span class="p">.</span><span class="nx">Success</span><span class="o">&lt;</span><span class="k">typeof</span> <span class="nx">Runtime</span><span class="p">.</span><span class="nx">AppLayer</span><span class="o">&gt;</span> <span class="o">|</span> <span class="nx">SessionServices</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="kr">declare</span> <span class="kr">module</span> <span class="dl">'</span><span class="s1">@app/kernel/routing</span><span class="dl">'</span> <span class="p">{</span>
  <span class="kr">interface</span> <span class="nx">Register</span> <span class="p">{</span>
    <span class="k">readonly</span> <span class="nx">routing</span><span class="p">:</span> <span class="k">typeof</span> <span class="nx">I18nRoutes</span><span class="p">.</span><span class="nx">i18nRouting</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="kr">declare</span> <span class="kr">module</span> <span class="dl">'</span><span class="s1">i18next</span><span class="dl">'</span> <span class="p">{</span>
  <span class="kr">interface</span> <span class="nx">CustomTypeOptions</span> <span class="p">{</span>
    <span class="k">readonly</span> <span class="nx">defaultNS</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ui</span><span class="dl">'</span><span class="p">;</span>
    <span class="k">readonly</span> <span class="nx">resources</span><span class="p">:</span> <span class="p">(</span><span class="k">typeof</span> <span class="nx">I18n</span><span class="p">.</span><span class="nx">resources</span><span class="p">)[</span><span class="dl">'</span><span class="s1">en</span><span class="dl">'</span><span class="p">];</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It’s dependency inversion at the type level. Imports only point toward the kernel, but because every module is
typechecked inside the application’s program, every module sees the <em>real</em> types:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">run()</code> accepts only effects whose services <code class="language-plaintext highlighter-rouge">AppLayer</code> actually provides.</li>
  <li>A link to a page that doesn’t exist, in any module, is a type error, because route keys are those package specifiers.</li>
  <li>A translation key that doesn’t exist is a type error.</li>
</ul>

<h2 id="one-session-kept-apart">One session, kept apart</h2>

<p>The bridge gives every use case the request’s whole context, and that context includes the session. Identity needs it,
because that’s where it keeps the citizen’s authentication. For every other module, though, a shared session is a risk.
There’s one per browser, shared by every module and by every tab the citizen has open. Left alone, it turns into the
place where modules quietly pass data to each other, and where two tabs overwrite each other’s work.</p>

<p>So each module gets its own slice of the session, and each tab gets its own slice of that.</p>

<h3 id="a-slice-per-bounded-context">A slice per bounded context</h3>

<p>Modules still keep the citizen’s real data in their own repositories, keyed by the citizen. The session is only for
short-lived state: what someone is doing during this visit. When a module needs that, it asks identity for its own view
of the session:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kr">interface</span> <span class="nx">ContextSession</span> <span class="p">{</span>
  <span class="k">readonly</span> <span class="nx">context</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="kd">get</span><span class="p">:</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">Effect</span><span class="o">&lt;</span><span class="nx">ContextData</span><span class="o">&gt;</span><span class="p">;</span>
  <span class="cm">/** Replaces this context's slice with `f(current)`. */</span>
  <span class="k">readonly</span> <span class="nx">update</span><span class="p">:</span> <span class="p">(</span><span class="nx">f</span><span class="p">:</span> <span class="p">(</span><span class="nx">current</span><span class="p">:</span> <span class="nx">ContextData</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">ContextData</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">Effect</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">clear</span><span class="p">:</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">Effect</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span><span class="p">;</span>
  <span class="cm">/** Reads another context's slice; denied unless `owner` is in this context's grants. */</span>
  <span class="k">readonly</span> <span class="nx">readFrom</span><span class="p">:</span> <span class="p">(</span><span class="nx">owner</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">Effect</span><span class="o">&lt;</span><span class="nx">ContextData</span><span class="p">,</span> <span class="nx">SessionAccessDenied</span><span class="o">&gt;</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">contextSession</span> <span class="o">=</span> <span class="p">(</span><span class="nx">options</span><span class="p">:</span> <span class="p">{</span>
  <span class="k">readonly</span> <span class="nx">context</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="cm">/** Other contexts whose slices this one may read. Empty by default. */</span>
  <span class="k">readonly</span> <span class="nx">mayReadFrom</span><span class="p">?:</span> <span class="k">readonly</span> <span class="kr">string</span><span class="p">[];</span>
<span class="p">}):</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">Effect</span><span class="o">&lt;</span><span class="nx">ContextSession</span><span class="p">,</span> <span class="nx">never</span><span class="p">,</span> <span class="nx">CurrentSession</span><span class="o">&gt;</span> <span class="o">=&gt;</span> <span class="cm">/* … */</span><span class="p">;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">contextSession</code> is in identity’s <code class="language-plaintext highlighter-rouge">published</code> list, as <code class="language-plaintext highlighter-rouge">./session.server</code>. The raw <code class="language-plaintext highlighter-rouge">CurrentSession</code> service isn’t. The
application imports it to type the bridge, but no module can import it, so no module can <code class="language-plaintext highlighter-rouge">yield*</code> it. The boundaries
script guards it the same way it guards every other module’s internals.</p>

<p>A module reads and writes its own slice, and that’s all it can write. It can read another context’s slice only if it
names that context in <code class="language-plaintext highlighter-rouge">mayReadFrom</code>. Otherwise <code class="language-plaintext highlighter-rouge">readFrom</code> fails with a typed <code class="language-plaintext highlighter-rouge">SessionAccessDenied</code>, and the exhaustive
<code class="language-plaintext highlighter-rouge">onError</code> you saw in the bridge makes the caller decide what that means. Identity’s authentication isn’t a slice, so no
grant reaches it. Even the names are checked: a context can’t call its slice <code class="language-plaintext highlighter-rouge">__proto__</code>.</p>

<p>A grant is a line of code next to the call, so letting one context look at another’s state shows up in a diff. It
can’t happen by accident.</p>

<p>There’s one more constraint, and it comes from running several replicas. During a rolling deployment, a replica may read
a session written by an older or a newer version of itself. So the stored shape of the session can only grow. The slices
went in as one new optional field, validated on the way in like everything else in the store.</p>

<h3 id="a-slice-per-tab">A slice per tab</h3>

<p>A slice per context still isn’t enough, because the session belongs to the browser, not to the tab. We already saw this
with the profile form, where someone may have changed the profile “in another tab”. The benefits finder ran into it next.</p>

<p>The finder now remembers the citizen’s last search, so coming back to <code class="language-plaintext highlighter-rouge">/en/benefits</code> picks up where they left off. Store
that search once per session, though, and two tabs fight over it. Search for dental care in one tab and pensions in
another, return to the first, and it shows you pensions.</p>

<p>The fix is our <a href="/2025/09/05/browser-tab-isolation.html">tab ID hook</a>. In the page, <code class="language-plaintext highlighter-rouge">useTabId()</code> gives each tab
a short ID of its own, kept in that tab’s <code class="language-plaintext highlighter-rouge">sessionStorage</code> and added to the URL as <code class="language-plaintext highlighter-rouge">tid</code>. In the loader,
<code class="language-plaintext highlighter-rouge">getTabId(request)</code> reads it back. The finder then keeps one selection per tab inside benefits’ slice:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">slice</span> <span class="o">=</span> <span class="nx">contextSession</span><span class="p">({</span> <span class="na">context</span><span class="p">:</span> <span class="dl">'</span><span class="s1">benefits</span><span class="dl">'</span> <span class="p">});</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">rememberSelection</span> <span class="o">=</span> <span class="p">(</span><span class="nx">tabId</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">selection</span><span class="p">:</span> <span class="nx">FinderSelection</span><span class="p">)</span> <span class="o">=&gt;</span>
  <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="p">{</span> <span class="nx">update</span> <span class="p">}</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">slice</span><span class="p">;</span>
    <span class="k">yield</span><span class="o">*</span> <span class="nx">update</span><span class="p">((</span><span class="nx">data</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="p">{</span> <span class="p">[</span><span class="nx">tabId</span><span class="p">]:</span> <span class="nx">_replaced</span><span class="p">,</span> <span class="p">...</span><span class="nx">others</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">tabsOf</span><span class="p">(</span><span class="nx">data</span><span class="p">);</span>
      <span class="c1">// Re-inserting last makes the oldest tabs the first to be dropped.</span>
      <span class="kd">const</span> <span class="nx">kept</span> <span class="o">=</span> <span class="nb">Object</span><span class="p">.</span><span class="nx">entries</span><span class="p">(</span><span class="nx">others</span><span class="p">).</span><span class="nx">slice</span><span class="p">(</span><span class="o">-</span><span class="p">(</span><span class="nx">MAX_TABS</span> <span class="o">-</span> <span class="mi">1</span><span class="p">));</span>
      <span class="k">return</span> <span class="p">{</span> <span class="p">...</span><span class="nx">data</span><span class="p">,</span> <span class="na">tabs</span><span class="p">:</span> <span class="p">{</span> <span class="p">...</span><span class="nb">Object</span><span class="p">.</span><span class="nx">fromEntries</span><span class="p">(</span><span class="nx">kept</span><span class="p">),</span> <span class="p">[</span><span class="nx">tabId</span><span class="p">]:</span> <span class="nx">selection</span> <span class="p">}</span> <span class="p">};</span>
    <span class="p">});</span>
  <span class="p">});</span>
</code></pre></div></div>

<p>The loader’s rule is simple. If the URL asks for something (a query, a situation or a view), that’s the tab’s new
selection, and it’s stored under the tab’s ID. If the URL asks for nothing, the tab gets its own last selection back. The
finder’s own links always name a view, so an otherwise empty URL is never mistaken for “give me my last search”.</p>

<p>A few small details keep this honest:</p>

<ul>
  <li><strong>A <code class="language-plaintext highlighter-rouge">tid</code> that doesn’t look like one the hook generates is ignored.</strong> It never becomes a key in the session.</li>
  <li><strong>At most ten tabs are remembered.</strong> The oldest is dropped first, so a long day of browsing can’t grow the session
without limit.</li>
  <li><strong>Session data is untyped JSON.</strong> So each field is parsed again on the way out, like any other input the module
didn’t just create.</li>
  <li><strong>The URL still wins.</strong> Without JavaScript there’s no tab ID, so the finder just reads the search from the URL, as it
always did. A search is still a plain <code class="language-plaintext highlighter-rouge">GET</code> form that you can bookmark.</li>
</ul>

<p>An end-to-end test opens two tabs on one session, searches for something different in each, and checks that each tab
gets its own search back.</p>

<h2 id="resilience-and-observability-at-the-seams">Resilience and observability at the seams</h2>

<p>A modular monolith can still have partial failures. The modules are in-process, but many of their adapters stand in
for real back-end systems that will be slow or down some of the time. The dashboard feels this most, because it
composes six or seven modules on one page.</p>

<p>The kernel has a tiny helper for that:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">type</span> <span class="nx">Part</span><span class="o">&lt;</span><span class="nx">A</span><span class="o">&gt;</span> <span class="o">=</span> <span class="p">{</span> <span class="k">readonly</span> <span class="na">state</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ready</span><span class="dl">'</span><span class="p">;</span> <span class="k">readonly</span> <span class="na">value</span><span class="p">:</span> <span class="nx">A</span> <span class="p">}</span> <span class="o">|</span> <span class="p">{</span> <span class="k">readonly</span> <span class="na">state</span><span class="p">:</span> <span class="dl">'</span><span class="s1">unavailable</span><span class="dl">'</span> <span class="p">};</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">settle</span> <span class="o">=</span> <span class="o">&lt;</span><span class="nx">A</span><span class="p">,</span> <span class="nx">E</span><span class="p">,</span> <span class="nx">R</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">name</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">effect</span><span class="p">:</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">Effect</span><span class="o">&lt;</span><span class="nx">A</span><span class="p">,</span> <span class="nx">E</span><span class="p">,</span> <span class="nx">R</span><span class="o">&gt;</span><span class="p">,</span> <span class="nx">timeout</span> <span class="o">=</span> <span class="nx">PART_TIMEOUT</span><span class="p">)</span> <span class="o">=&gt;</span>
  <span class="nx">effect</span><span class="p">.</span><span class="nx">pipe</span><span class="p">(</span>
    <span class="nx">Effect</span><span class="p">.</span><span class="nx">timeout</span><span class="p">(</span><span class="nx">timeout</span><span class="p">),</span>
    <span class="nx">Effect</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">value</span><span class="p">):</span> <span class="nx">Part</span><span class="o">&lt;</span><span class="nx">A</span><span class="o">&gt;</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="na">state</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ready</span><span class="dl">'</span><span class="p">,</span> <span class="nx">value</span> <span class="p">})),</span>
    <span class="nx">Effect</span><span class="p">.</span><span class="nx">catchCause</span><span class="p">((</span><span class="nx">cause</span><span class="p">)</span> <span class="o">=&gt;</span>
      <span class="nx">Effect</span><span class="p">.</span><span class="nx">logWarning</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">name</span><span class="p">}</span><span class="s2"> unavailable; showing the page without it`</span><span class="p">,</span> <span class="nx">cause</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
        <span class="nx">Effect</span><span class="p">.</span><span class="k">as</span><span class="o">&lt;</span><span class="nx">Part</span><span class="o">&lt;</span><span class="nx">A</span><span class="o">&gt;&gt;</span><span class="p">({</span> <span class="na">state</span><span class="p">:</span> <span class="dl">'</span><span class="s1">unavailable</span><span class="dl">'</span> <span class="p">}),</span>
      <span class="p">),</span>
    <span class="p">),</span>
  <span class="p">);</span>
</code></pre></div></div>

<p>The home page’s use case runs each part through it, all at once:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">claim</span> <span class="o">=</span> <span class="nx">settle</span><span class="p">(</span><span class="dl">'</span><span class="s1">home: claim</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="k">yield</span><span class="o">*</span> <span class="nx">ClaimStatus</span><span class="p">).</span><span class="k">of</span><span class="p">(</span><span class="nx">user</span><span class="p">));</span>
<span class="kd">const</span> <span class="nx">applications</span> <span class="o">=</span> <span class="nx">settle</span><span class="p">(</span><span class="dl">'</span><span class="s1">home: job applications</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="k">yield</span><span class="o">*</span> <span class="nx">JobApplicationsGlance</span><span class="p">).</span><span class="k">of</span><span class="p">(</span><span class="nx">user</span><span class="p">));</span>
<span class="kd">const</span> <span class="nx">payments</span> <span class="o">=</span> <span class="nx">settle</span><span class="p">(</span><span class="dl">'</span><span class="s1">home: payments</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="k">yield</span><span class="o">*</span> <span class="nx">PaymentsGlance</span><span class="p">).</span><span class="nx">recent</span><span class="p">(</span><span class="nx">user</span><span class="p">,</span> <span class="mi">3</span><span class="p">));</span>
<span class="c1">// …</span>

<span class="kd">const</span> <span class="p">[</span><span class="nx">claimed</span><span class="p">,</span> <span class="nx">applied</span><span class="p">,</span> <span class="nx">paid</span><span class="p">,</span> <span class="nx">life</span><span class="p">,</span> <span class="nx">matches</span><span class="p">,</span> <span class="nx">news</span><span class="p">]</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">all</span><span class="p">(</span>
  <span class="p">[</span><span class="nx">claim</span><span class="p">,</span> <span class="nx">applications</span><span class="p">,</span> <span class="nx">payments</span><span class="p">,</span> <span class="nx">lifeEvent</span><span class="p">,</span> <span class="nx">jobMatches</span><span class="p">,</span> <span class="nx">whatsNew</span><span class="p">],</span>
  <span class="p">{</span> <span class="na">concurrency</span><span class="p">:</span> <span class="dl">'</span><span class="s1">unbounded</span><span class="dl">'</span> <span class="p">},</span>
<span class="p">);</span>
</code></pre></div></div>

<p><img src="/assets/images/2026-11-10-modular-monolith/settle-fanout.svg" alt="Six parts start together; one fails and one times out at 1.5 seconds; both become unavailable and the page still renders" class="diagram" style="background:none;padding:0" /></p>

<p>A typed failure, a defect or a timeout all end up as the same thing: a value the page knows how to render. A slow
payments system costs the citizen one card, not the whole page.</p>

<p>Observability comes almost for free from the bridge. Because <code class="language-plaintext highlighter-rouge">run()</code> executes inside the request’s context, the trace
for our request nests naturally:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /en/jobs                       HTTP server span (Tracing middleware)
└── jobs.search                    run() in the loader
    └── SearchJobs                 the use case
</code></pre></div></div>

<p>Every log line along the way carries the same <code class="language-plaintext highlighter-rouge">requestId</code>. Spans can go to the console or to any OTLP endpoint.</p>

<p>Testing failure is part of the design too. In test mode (off by default; when it’s off, these endpoints answer 404),
the application exposes two endpoints, both protected by a shared secret. One resets every module to its seed data. The other
switches a simulated back-end outage on or off. The composition root implements both on top of each module’s
unpublished demo entry. The end-to-end suite uses them to prove that, for example, the dashboard still renders with
the payments system down.</p>

<h2 id="one-exception-an-area-that-ships-on-its-own">One exception: an area that ships on its own</h2>

<p>At the start I said every team rides the same release train. That’s true for every module. But one area of this
account isn’t really ours. Tax slips belong to the Canada Revenue Agency (CRA): a different organization, with its own
team and its own release schedule. The citizen shouldn’t notice any of that. They should see the same header, the same
side navigation, the same session and the same language toggle.</p>

<p>So I made one explicit, limited exception: an <strong>island</strong>. It’s a separate frontend, built and deployed on its own, and
mounted inside the account’s chrome under <code class="language-plaintext highlighter-rouge">/en/canada-revenue-agency/*</code> and <code class="language-plaintext highlighter-rouge">/fr/agence-du-revenu-du-canada/*</code>. It is
not the first step toward turning modules into micro-frontends. That isn’t on the table. It’s what we do for the rare
area that can’t share our build.</p>

<p>To keep this concrete, here’s a third request: the same citizen opens <code class="language-plaintext highlighter-rouge">/en/canada-revenue-agency/slips/T4-2025-0003-1</code>
to look at last year’s T4. Almost everything up to the loader is what you’ve already seen: the account layout, the
<code class="language-plaintext highlighter-rouge">requireSignedIn</code> and <code class="language-plaintext highlighter-rouge">requireConsent</code> middleware, and <code class="language-plaintext highlighter-rouge">run()</code>. Only the body of the page is different.</p>

<p><img src="/assets/images/2026-11-10-modular-monolith/island-anatomy.svg" alt="One page, two runtimes: the host renders the chrome on the server; the island renders inside a shadow root in the browser; only the mount contract crosses between them" class="diagram" style="background:none;padding:0" /></p>

<h3 id="the-host-is-just-another-module">The host is just another module</h3>

<p>The host side of the island is an ordinary bounded context, <code class="language-plaintext highlighter-rouge">@app/mfe-host</code>, built the same way as every other module.
Its domain knows what a remote manifest and a mount path are. Its ports are a <code class="language-plaintext highlighter-rouge">RemoteRegistry</code>, a <code class="language-plaintext highlighter-rouge">ManifestSource</code> and
<code class="language-plaintext highlighter-rouge">DelegatedAccess</code>. It reaches identity through an anti-corruption layer, publishes nothing, and sits in the second tier
of the composition root, next to profile.</p>

<p>Its route table owns one splat page per remote:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">account</span><span class="p">:</span> <span class="p">[</span>
  <span class="p">{</span>
    <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">MFE-0001</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">file</span><span class="p">:</span> <span class="dl">'</span><span class="s1">@app/mfe-host/routes/cra-tax-slips.tsx</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">paths</span><span class="p">:</span> <span class="p">{</span> <span class="na">en</span><span class="p">:</span> <span class="dl">'</span><span class="s1">/en/canada-revenue-agency/*</span><span class="dl">'</span><span class="p">,</span> <span class="na">fr</span><span class="p">:</span> <span class="dl">'</span><span class="s1">/fr/agence-du-revenu-du-canada/*</span><span class="dl">'</span> <span class="p">},</span>
  <span class="p">},</span>
<span class="p">],</span>
</code></pre></div></div>

<p>Because it’s an <code class="language-plaintext highlighter-rouge">account</code> page, it gets the account layout and its guards for free. Everything under the prefix belongs
to the island.</p>

<p>The island itself is a second <em>application</em>, <code class="language-plaintext highlighter-rouge">apps/cra-tax-slips/</code>, with its own build, its own container image, its own
deployment and its own team in <code class="language-plaintext highlighter-rouge">CODEOWNERS</code>. The boundaries script has a rule for it: an application other than the main
one may depend on shared packages only. It can’t import a module, published API or not.</p>

<h3 id="the-contract-is-a-package-with-no-dependencies">The contract is a package with no dependencies</h3>

<p>The only thing the host and the island share is a small package, <code class="language-plaintext highlighter-rouge">@app/mfe-contract</code>. It has no dependencies at all.
The kernel can’t hold it, because the kernel imports React and the island doesn’t use React.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">MOUNT_CONTRACT</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">app-mfe/1</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kr">interface</span> <span class="nx">MountContext</span> <span class="p">{</span>
  <span class="cm">/** The host path the remote owns: `/en/canada-revenue-agency`. */</span>
  <span class="k">readonly</span> <span class="nx">basename</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="cm">/** A change of language is a new document, so a new mount. */</span>
  <span class="k">readonly</span> <span class="nx">language</span><span class="p">:</span> <span class="dl">'</span><span class="s1">en</span><span class="dl">'</span> <span class="o">|</span> <span class="dl">'</span><span class="s1">fr</span><span class="dl">'</span><span class="p">;</span>
  <span class="cm">/** The host owns history. A target outside `basename` is refused. */</span>
  <span class="nx">navigate</span><span class="p">(</span><span class="nx">to</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">options</span><span class="p">?:</span> <span class="p">{</span> <span class="k">readonly</span> <span class="nx">replace</span><span class="p">?:</span> <span class="nx">boolean</span> <span class="p">}):</span> <span class="k">void</span><span class="p">;</span>
  <span class="cm">/** A short-lived bearer token for the remote's API. */</span>
  <span class="nx">getAccessToken</span><span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="kr">string</span><span class="o">&gt;</span><span class="p">;</span>
  <span class="cm">/** Called on every host navigation under `basename`. Returns an unsubscribe. */</span>
  <span class="nx">onLocationChange</span><span class="p">(</span><span class="nx">listener</span><span class="p">:</span> <span class="p">(</span><span class="nx">location</span><span class="p">:</span> <span class="nx">MountLocation</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">void</span><span class="p">):</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">void</span><span class="p">;</span>
<span class="p">}</span>

<span class="cm">/** Idempotent: aborts in-flight work, removes listeners and empties what it rendered. */</span>
<span class="k">export</span> <span class="kd">type</span> <span class="nx">Unmount</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">void</span><span class="p">;</span>

<span class="k">export</span> <span class="kr">interface</span> <span class="nx">RemoteModule</span> <span class="p">{</span>
  <span class="k">readonly</span> <span class="nx">contract</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="nx">mount</span><span class="p">(</span><span class="nx">el</span><span class="p">:</span> <span class="nx">HTMLElement</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">:</span> <span class="nx">MountContext</span><span class="p">):</span> <span class="nx">Unmount</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If you come from Java, think of it as an SPI jar: one interface, implemented on the other side of a deployment boundary.
Versioning is deliberately strict. The host accepts the same major version only. A minor version may add optional fields
and nothing else. A breaking change becomes <code class="language-plaintext highlighter-rouge">app-mfe/2</code>, and the host then supports both versions while remotes migrate.</p>

<p>This is the one place in the post where a boundary is checked at run time instead of by the compiler. The compiler still
checks each side against the contract. What it can’t check is that the code the host loads tomorrow was built against
the same version. A version check and an integrity hash do that job instead.</p>

<h3 id="one-router">One router</h3>

<p>The island lives in the host’s page, so it must not fight the host over the URL. The host’s React Router owns the
browser’s history, and the island never calls <code class="language-plaintext highlighter-rouge">history.pushState</code> itself. Its own paths are language-neutral (<code class="language-plaintext highlighter-rouge">/</code> and
<code class="language-plaintext highlighter-rouge">/slips/:slipId</code>). When it wants to move, it asks:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">navigate</span><span class="p">:</span> <span class="p">(</span><span class="nx">to</span><span class="p">,</span> <span class="nx">options</span> <span class="o">=</span> <span class="p">{})</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">target</span> <span class="o">=</span> <span class="nx">withinBasename</span><span class="p">(</span><span class="nx">basename</span><span class="p">,</span> <span class="nx">to</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">target</span> <span class="o">===</span> <span class="kc">undefined</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nx">warn</span><span class="p">(</span><span class="s2">`refused a remote's navigation outside </span><span class="p">${</span><span class="nx">basename</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
    <span class="k">return</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="k">void</span> <span class="nx">latest</span><span class="p">.</span><span class="nx">current</span><span class="p">.</span><span class="nx">navigate</span><span class="p">(</span><span class="nx">target</span><span class="p">,</span> <span class="p">{</span> <span class="na">replace</span><span class="p">:</span> <span class="nx">options</span><span class="p">.</span><span class="nx">replace</span> <span class="o">===</span> <span class="kc">true</span> <span class="p">});</span>
<span class="p">},</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">onLocationChange</code> is there for the other direction. React Router’s <code class="language-plaintext highlighter-rouge">pushState</code> fires no <code class="language-plaintext highlighter-rouge">popstate</code> event, so without a
callback the island couldn’t follow the host’s own links: the side navigation, back and forward. The host’s outlet
watches <code class="language-plaintext highlighter-rouge">useLocation()</code> and tells the island whenever the path is still under its prefix.</p>

<p>The language toggle swaps only the bilingual prefix (<code class="language-plaintext highlighter-rouge">/en/canada-revenue-agency</code> becomes
<code class="language-plaintext highlighter-rouge">/fr/agence-du-revenu-du-canada</code>), keeps the rest of the path and reloads the document. A new language is simply a new
mount. The end-to-end tests check that the side navigation can move into and out of the island without reloading the
document, and that the toggle keeps the slip that was open.</p>

<h3 id="a-different-runtime-the-same-architecture">A different runtime, the same architecture</h3>

<p>The island uses <strong>Preact</strong>, not React, and it uses neither Effect nor our design system. That’s on purpose. A different
runtime proves the two sides really share nothing: no shared React, no clash between two copies of React’s hooks, and
no import maps or Module Federation to keep in step. Preact is about 4 KB, and its TSX looks familiar.</p>

<p>Inside, though, it’s built the way everything else in this post is built: a <code class="language-plaintext highlighter-rouge">domain/</code> (tax slips, tax years, money), an
<code class="language-plaintext highlighter-rouge">application/</code> with ports (<code class="language-plaintext highlighter-rouge">TaxSlipsApi</code>, <code class="language-plaintext highlighter-rouge">AccessTokenProvider</code>, <code class="language-plaintext highlighter-rouge">Navigator</code>) and use cases, <code class="language-plaintext highlighter-rouge">infrastructure/</code> adapters,
a <code class="language-plaintext highlighter-rouge">ui/</code>, and a composition root for each mount. The boundaries script’s layering rule now covers it too: no Preact in
<code class="language-plaintext highlighter-rouge">domain/</code> or <code class="language-plaintext highlighter-rouge">application/</code>.</p>

<p>The entry the host loads is tiny:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">contract</span><span class="p">:</span> <span class="kr">string</span> <span class="o">=</span> <span class="nx">MOUNT_CONTRACT</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">mount</span> <span class="o">=</span> <span class="p">(</span><span class="nx">el</span><span class="p">:</span> <span class="nx">HTMLElement</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">:</span> <span class="nx">MountContext</span><span class="p">):</span> <span class="nx">Unmount</span> <span class="o">=&gt;</span>
  <span class="nx">createMount</span><span class="p">({</span> <span class="nx">css</span><span class="p">,</span> <span class="na">api</span><span class="p">:</span> <span class="p">(</span><span class="nx">tokens</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">new</span> <span class="nx">HttpTaxSlipsApi</span><span class="p">(</span><span class="nx">apiBase</span><span class="p">(),</span> <span class="nx">tokens</span><span class="p">)</span> <span class="p">})(</span><span class="nx">el</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">createMount</code> attaches an open shadow root to <code class="language-plaintext highlighter-rouge">el</code> and puts the island’s styles inside it. That keeps the island’s CSS
from leaking out, and the host’s CSS from leaking in. The exceptions are inherited fonts, colours and CSS custom
properties, which is what we want. To be clear, the shadow root only scopes <strong>styles</strong>. The island’s script can still
reach <code class="language-plaintext highlighter-rouge">document</code> like any other script on the page.</p>

<p>Each mount also gets one <code class="language-plaintext highlighter-rouge">AbortController</code>, which <code class="language-plaintext highlighter-rouge">Unmount</code> aborts synchronously. Preact runs effect cleanups after the
next paint, and I didn’t want a request from a page the citizen has already left to land after that.</p>

<h3 id="finding-and-loading-code-you-didnt-compile">Finding and loading code you didn’t compile</h3>

<p>This is the part that needs the most care. The host is about to run JavaScript it didn’t build.</p>

<p>The trust starts in the host’s code, not in configuration. The host keeps a registry of approved remotes, and for each
one it fixes the contract’s major version, the API audience and the scopes the island may be given. Only the URLs come
from configuration, and only their origins are allowed. The island’s build publishes a manifest beside its one hashed
entry file:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cra-tax-slips"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"contract"</span><span class="p">:</span><span class="w"> </span><span class="s2">"app-mfe/1"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"entry"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mount.CQsOWGlR.js"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"integrity"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sha384-75dc/UUMp40U9yBX7sjtF7Uy243rZNhCyzB0JgHMMuNn7KqSWtEJYDOipLmBXWcR"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"api"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"baseUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"api/v1/"</span><span class="p">,</span><span class="w"> </span><span class="nl">"audience"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cra-tax-slips-api"</span><span class="p">,</span><span class="w"> </span><span class="nl">"scopes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"tax-slips:read"</span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>On the server, the page’s loader runs a <code class="language-plaintext highlighter-rouge">ResolveRemoteMount</code> use case through <code class="language-plaintext highlighter-rouge">run()</code>. It reads the manifest through
the <code class="language-plaintext highlighter-rouge">ManifestSource</code> port and treats it as untrusted input. It parses every field and checks the result against the
registry: the same contract major version, the same audience, and an entry and API on the approved origin over HTTPS.
A manifest can’t approve a new origin or ask for a wider scope.</p>

<p>The HTTP adapter for that port lives in the composition root, not in the module, because modules don’t touch the
network. It allows 1.5 seconds, refuses redirects (so a manifest can’t send the host somewhere else), caps the size at
64 KB and caches a good answer for a minute.</p>

<p>Every way this can go wrong is a typed error, and the loader turns each one into a state the page can render:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">ResolveRemoteMount</span><span class="p">(</span><span class="dl">'</span><span class="s1">cra-tax-slips</span><span class="dl">'</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
  <span class="nx">Effect</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">remote</span><span class="p">):</span> <span class="nx">RemotePage</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="na">state</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ready</span><span class="dl">'</span><span class="p">,</span> <span class="nx">remote</span><span class="p">,</span> <span class="nx">basename</span><span class="p">,</span> <span class="nx">language</span> <span class="p">})),</span>
  <span class="nx">Effect</span><span class="p">.</span><span class="nx">catchTags</span><span class="p">({</span>
    <span class="na">RemoteNotRegistered</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">unavailable</span><span class="p">(</span><span class="dl">'</span><span class="s1">not registered</span><span class="dl">'</span><span class="p">),</span>
    <span class="na">RemoteUnavailable</span><span class="p">:</span> <span class="p">({</span> <span class="nx">reason</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="nx">unavailable</span><span class="p">(</span><span class="nx">reason</span><span class="p">),</span>
    <span class="na">ManifestInvalid</span><span class="p">:</span> <span class="p">({</span> <span class="nx">reason</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="nx">unavailable</span><span class="p">(</span><span class="s2">`invalid manifest: </span><span class="p">${</span><span class="nx">reason</span><span class="p">}</span><span class="s2">`</span><span class="p">),</span>
    <span class="na">OriginNotApproved</span><span class="p">:</span> <span class="p">({</span> <span class="nx">url</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="nx">unavailable</span><span class="p">(</span><span class="s2">`unapproved origin: </span><span class="p">${</span><span class="nx">url</span><span class="p">}</span><span class="s2">`</span><span class="p">),</span>
    <span class="na">RemoteIncompatible</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">incompatible</span><span class="p">(),</span>
  <span class="p">}),</span>
<span class="p">)</span>
</code></pre></div></div>

<p>What reaches the browser is an entry URL and its integrity hash. There’s no token and nothing secret. Server rendering
stops at the host’s chrome, the heading and a loading state. The island renders in the browser.</p>

<p>In the browser, the host’s outlet loads the entry in two steps:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// The one place in the repository allowed to import() a URL it doesn't know at build time.</span>
<span class="k">await</span> <span class="nx">preload</span><span class="p">(</span><span class="nx">entryUrl</span><span class="p">,</span> <span class="nx">integrity</span><span class="p">,</span> <span class="nx">timeoutMs</span><span class="p">);</span> <span class="c1">// &lt;link rel="modulepreload" integrity="…" crossorigin&gt;</span>
<span class="kd">const</span> <span class="nx">loaded</span><span class="p">:</span> <span class="nx">unknown</span> <span class="o">=</span> <span class="k">await</span> <span class="k">import</span><span class="p">(</span><span class="cm">/* @vite-ignore */</span> <span class="nx">entryUrl</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">isRemoteModule</span><span class="p">(</span><span class="nx">loaded</span><span class="p">))</span> <span class="k">throw</span> <span class="k">new</span> <span class="nx">RemoteLoadFailed</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">entryUrl</span><span class="p">}</span><span class="s2"> does not export this host's mount contract`</span><span class="p">);</span>
</code></pre></div></div>

<p>The browser checks the preloaded bytes against the hash before anything runs. If the hash doesn’t match, the network
fails or the load times out, the outlet stops. <strong>None of the island’s code runs.</strong> This is what the boundaries script’s
“no dynamic code” rule protects: a non-literal <code class="language-plaintext highlighter-rouge">import()</code> is allowed in exactly this one file.</p>

<p><img src="/assets/images/2026-11-10-modular-monolith/island-trust-chain.svg" alt="Loading the island: the registry in code anchors trust; the manifest is checked as untrusted input; the browser verifies the entry's hash before running it; every step can refuse and fall back to the notice" class="diagram" style="background:none;padding:0" /></p>

<p>Integrity checks are easy to oversell, so here’s exactly what this one covers. It stops a stale, swapped or tampered
entry file from running in place of the one the manifest names. The island’s build enforces a single entry chunk, so
that hash covers all of its code. It does <strong>not</strong> protect against someone who controls the island’s own server, because
they could change the manifest and the entry together.</p>

<p>One React Router detail is worth stealing. The page’s descriptor doesn’t change while the citizen moves around inside the
island, or when the island asks for a token. Neither should reload the loader, because that would remount the island:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">shouldRevalidate</span><span class="p">:</span> <span class="nx">ShouldRevalidateFunction</span> <span class="o">=</span> <span class="p">({</span> <span class="nx">formAction</span><span class="p">,</span> <span class="nx">formMethod</span><span class="p">,</span> <span class="nx">currentUrl</span><span class="p">,</span> <span class="nx">nextUrl</span><span class="p">,</span> <span class="nx">defaultShouldRevalidate</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">formAction</span><span class="p">?.</span><span class="nx">startsWith</span><span class="p">(</span><span class="nx">ACCESS_PREFIX</span><span class="p">)</span> <span class="o">===</span> <span class="kc">true</span><span class="p">)</span> <span class="k">return</span> <span class="kc">false</span><span class="p">;</span> <span class="c1">// a token request</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">formMethod</span> <span class="o">===</span> <span class="kc">undefined</span> <span class="o">&amp;&amp;</span> <span class="nx">currentUrl</span><span class="p">.</span><span class="nx">href</span> <span class="o">!==</span> <span class="nx">nextUrl</span><span class="p">.</span><span class="nx">href</span><span class="p">)</span> <span class="k">return</span> <span class="kc">false</span><span class="p">;</span> <span class="c1">// moving inside the island</span>
  <span class="k">return</span> <span class="nx">defaultShouldRevalidate</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div></div>

<h3 id="the-one-token-that-reaches-the-browser">The one token that reaches the browser</h3>

<p>Earlier I wrote that tokens from the identity provider never reach the browser. That’s still true, and neither does the
session. But the island’s API is served by another deployment, and it needs to know who’s asking. So the island gets a
different, much narrower token. It is the one documented exception to the rule.</p>

<p>Identity mints it. The identity module publishes a <code class="language-plaintext highlighter-rouge">DelegatedTokenIssuer</code> over a <code class="language-plaintext highlighter-rouge">DelegationSigner</code> port. The local
adapter signs with an ES256 key of its own, which isn’t the session secret or the OIDC client key, and identity serves
the public half as a JWKS. The subject always comes from the request’s <code class="language-plaintext highlighter-rouge">CurrentUser</code>. A caller can name only the
audience and the scopes, and the host takes those from its registry. The host reaches all this through its own
anti-corruption layer, just as jobs reaches profile.</p>

<p>The browser asks for a token with a React Router fetcher posting to a host route, <code class="language-plaintext highlighter-rouge">POST /mfe-access/:remoteId</code>, so the
host module still never calls <code class="language-plaintext highlighter-rouge">fetch</code> itself. That route checks, in order:</p>

<ol>
  <li>The method is <code class="language-plaintext highlighter-rouge">POST</code>. Otherwise it answers 405.</li>
  <li>The <code class="language-plaintext highlighter-rouge">Origin</code> header is the host’s own origin. Otherwise 403. This stops cross-site callers. It doesn’t, and can’t,
tell the island apart from the rest of the page.</li>
  <li>The body doesn’t try to choose a subject, an audience or a scope. Otherwise 400.</li>
  <li>A citizen is signed in. Otherwise 401, as data and not a redirect, because a script asked, not a person.</li>
  <li>The citizen has consented. Otherwise 403.</li>
</ol>

<p>Then it runs <code class="language-plaintext highlighter-rouge">GrantRemoteAccess</code> through <code class="language-plaintext highlighter-rouge">run()</code>, with the same exhaustive <code class="language-plaintext highlighter-rouge">onError</code> you saw earlier: an unknown remote
is a 404, a refusal a 403, and an unavailable signer a 503. Every answer is <code class="language-plaintext highlighter-rouge">Cache-Control: no-store</code>.</p>

<p>The token itself is as small as I could make it. It uses the <code class="language-plaintext highlighter-rouge">at+jwt</code> type from RFC 9068 and ES256 with a key ID. It
names one audience (the island’s API) and one scope (<code class="language-plaintext highlighter-rouge">tax-slips:read</code>), and it lives for at most 120 seconds. It has a
random <code class="language-plaintext highlighter-rouge">jti</code> and no name, email, session ID or identity-provider token. It never appears in loader data, HTML, URLs, web
storage or logs. The island keeps it in a closure for one mount, reuses it until 15 seconds before it expires, and drops
it when it unmounts.</p>

<p>The island’s API verifies the token against the host’s JWKS and trusts nothing else:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="p">{</span> <span class="nx">payload</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">jwtVerify</span><span class="p">(</span><span class="nx">token</span><span class="p">,</span> <span class="nx">keys</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">algorithms</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">ES256</span><span class="dl">'</span><span class="p">],</span>
  <span class="na">typ</span><span class="p">:</span> <span class="dl">'</span><span class="s1">at+jwt</span><span class="dl">'</span><span class="p">,</span>
  <span class="nx">issuer</span><span class="p">,</span>
  <span class="nx">audience</span><span class="p">,</span>
  <span class="na">clockTolerance</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span>
  <span class="na">requiredClaims</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">sub</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">exp</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">iat</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">scope</span><span class="dl">'</span><span class="p">],</span>
<span class="p">});</span>
</code></pre></div></div>

<p>It then checks the scope and selects records by the verified subject only. Asking for another citizen’s slip gets a
“not found”, and there’s an end-to-end test for that.</p>

<p>Here’s what I accepted, stated plainly. This is a bearer token, not one bound to the sender. While it lives, any script
on the page can read it. It can’t be revoked, so it stays valid for up to two minutes after the citizen signs out. The
alternative is a backend-for-frontend in identity that proxies the island’s API. That keeps the token on the server, but
it changes the contract and puts the island’s traffic through our deployment. For a proof of concept with synthetic
data, two minutes was an acceptable price. Before production it gets another look, along with whether the subject
should be a pairwise identifier per audience.</p>

<h3 id="when-the-island-is-down">When the island is down</h3>

<p>The island fails like any other part of the page: in its own space. If the manifest can’t be read, doesn’t parse, names
an unapproved origin or speaks another major version, the page still answers 200. The chrome, the side navigation and
the language toggle all work, and the page shows a localized notice with a <strong>Try again</strong> button. If the entry itself
fails to load or throws while mounting, the outlet shows the same notice.</p>

<p>Two details make Try again actually work. A browser remembers a failed module URL for the life of the document, so a
retry adds <code class="language-plaintext highlighter-rouge">?attempt=N</code> to the URL. And a result that arrives after the citizen has left the page is never mounted.</p>

<p>By default the host doesn’t read any manifests at all, so it boots and stays ready without the island. In that mode the
tax slips page simply shows its notice. The end-to-end tests cover the outage, the recovery, and a tampered entry that
must fail closed.</p>

<h3 id="a-piece-of-the-island-on-someone-elses-page">A piece of the island on someone else’s page</h3>

<p>The island owns a whole subtree of paths, and sometimes that’s more than you need. The dashboard wanted a “Latest tax
slips” tile on the account’s home page, beside its own tiles for EI, jobs and payments. The tile’s data, code and release
schedule are all CRA’s. The page around it is the dashboard’s.</p>

<p>So a remote can now export <strong>fragments</strong> as well: small pieces that a module’s page shows in a slot, with no path of their
own.</p>

<p>Mounting one turned out to be the easy part. The hard part was the module graph. <code class="language-plaintext highlighter-rouge">mfe-host</code> publishes nothing, and that’s
deliberate. The dashboard could import it without breaking a rule, but the host already depends on identity and profile.
The day profile wanted a fragment, the graph would have a cycle. Besides, a module has no business knowing about
manifests and entry URLs.</p>

<p>The way out is the same inversion the kernel already uses for its types. The kernel declares a slot, and the application
fills it:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/** The fragments a module's page may show, as `&lt;remote&gt;/&lt;fragment&gt;`. */</span>
<span class="k">export</span> <span class="kr">interface</span> <span class="nx">RemoteFragmentRegister</span> <span class="p">{</span>
  <span class="k">readonly</span> <span class="dl">'</span><span class="s1">cra-tax-slips/latest-slips</span><span class="dl">'</span><span class="p">:</span> <span class="kc">true</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">export</span> <span class="kd">type</span> <span class="nx">RemoteFragmentId</span> <span class="o">=</span> <span class="kr">keyof</span> <span class="nx">RemoteFragmentRegister</span><span class="p">;</span>

<span class="cm">/** Provided by the application's signed-in layout; absent in tests and outside it. */</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">RemoteFragmentContext</span> <span class="o">=</span> <span class="nx">createContext</span><span class="o">&lt;</span><span class="nx">ComponentType</span><span class="o">&lt;</span><span class="nx">RemoteFragmentContentProps</span><span class="o">&gt;</span> <span class="o">|</span> <span class="kc">undefined</span><span class="o">&gt;</span><span class="p">(</span><span class="kc">undefined</span><span class="p">);</span>
</code></pre></div></div>

<p>The dashboard renders the kernel’s <code class="language-plaintext highlighter-rouge">RemoteFragment</code> and names the fragment it wants. It owns the frame and the heading,
and it passes the translated text:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nc">RemoteFragment</span> <span class="na">id</span><span class="p">=</span><span class="s">"cra-tax-slips/latest-slips"</span> <span class="na">as</span><span class="p">=</span><span class="s">"li"</span> <span class="na">heading</span><span class="p">=</span><span class="si">{</span><span class="nx">t</span><span class="p">(</span><span class="dl">'</span><span class="s1">home.tiles.slips.heading</span><span class="dl">'</span><span class="p">)</span><span class="si">}</span> <span class="na">headingLevel</span><span class="p">=</span><span class="si">{</span><span class="mi">3</span><span class="si">}</span> <span class="p">/&gt;</span>
</code></pre></div></div>

<p>The account layout provides the host’s implementation, so every <code class="language-plaintext highlighter-rouge">account</code> page gets it and nothing else does:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nc">RemoteFragmentContext</span> <span class="na">value</span><span class="p">=</span><span class="si">{</span><span class="nx">FragmentSlot</span><span class="si">}</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nc">AppChrome</span> <span class="na">user</span><span class="p">=</span><span class="si">{</span><span class="nx">loaderData</span><span class="p">.</span><span class="nx">user</span><span class="si">}</span><span class="p">&gt;</span><span class="si">{</span><span class="cm">/* … */</span><span class="si">}</span><span class="p">&lt;/</span><span class="nc">AppChrome</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nc">RemoteFragmentContext</span><span class="p">&gt;</span>
</code></pre></div></div>

<p>A module names a fragment. It never sees an entry URL, an integrity hash or a token. An identifier that isn’t in the
register is a type error, just like a link to a page that doesn’t exist. The register lives in the kernel and belongs to
the architecture group, so putting a fragment on a page is a reviewed decision. That’s governance, not authorization,
though: the token route can’t tell which page asked. The boundaries script has tests for both halves. Any module may
render the slot through the kernel with no new module edge, and a module that imports <code class="language-plaintext highlighter-rouge">mfe-host</code> is still refused.</p>

<p>Fragments are an optional addition to the remote’s entry, with a protocol of their own, <code class="language-plaintext highlighter-rouge">app-mfe-fragment/1</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kr">interface</span> <span class="nx">FragmentContext</span> <span class="p">{</span>
  <span class="k">readonly</span> <span class="nx">language</span><span class="p">:</span> <span class="dl">'</span><span class="s1">en</span><span class="dl">'</span> <span class="o">|</span> <span class="dl">'</span><span class="s1">fr</span><span class="dl">'</span><span class="p">;</span>
  <span class="cm">/** The heading level the host's frame uses, so the fragment nests below it. */</span>
  <span class="k">readonly</span> <span class="nx">headingLevel</span><span class="p">:</span> <span class="mi">2</span> <span class="o">|</span> <span class="mi">3</span> <span class="o">|</span> <span class="mi">4</span> <span class="o">|</span> <span class="mi">5</span> <span class="o">|</span> <span class="mi">6</span><span class="p">;</span>
  <span class="nx">getAccessToken</span><span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="kr">string</span><span class="o">&gt;</span><span class="p">;</span>
  <span class="cm">/** A host-approved destination inside the remote's own page, as an href; undefined if not approved. */</span>
  <span class="nx">href</span><span class="p">(</span><span class="nx">destination</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="kr">string</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">;</span>
  <span class="nx">navigate</span><span class="p">(</span><span class="nx">destination</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="k">void</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">export</span> <span class="kr">interface</span> <span class="nx">RemoteModule</span> <span class="p">{</span>
  <span class="k">readonly</span> <span class="nx">contract</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
  <span class="nx">mount</span><span class="p">(</span><span class="nx">el</span><span class="p">:</span> <span class="nx">HTMLElement</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">:</span> <span class="nx">MountContext</span><span class="p">):</span> <span class="nx">Unmount</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">fragments</span><span class="p">?:</span> <span class="nb">Readonly</span><span class="o">&lt;</span><span class="nb">Record</span><span class="o">&lt;</span><span class="kr">string</span><span class="p">,</span> <span class="nx">RemoteFragment</span><span class="o">&gt;&gt;</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A fragment gets less than a page does. It owns no path, so it has no <code class="language-plaintext highlighter-rouge">basename</code>, no <code class="language-plaintext highlighter-rouge">onLocationChange</code> and no free-form
<code class="language-plaintext highlighter-rouge">navigate</code>. It can only link into the remote’s own page, and the host refuses anything else. The page contract stays at
<code class="language-plaintext highlighter-rouge">app-mfe/1</code>, so a page-only remote and an older host both keep working. The fragment protocol can change without forcing
every page to migrate.</p>

<p>The slot resolves its fragment in the browser, after hydration. Server rendering produces the frame, the heading and a
placeholder sized for the content, so the page barely shifts when the tile arrives. The dashboard’s loader doesn’t change
at all, which keeps a slow manifest out of the page’s loader. Instead, the host’s outlet asks a host resource route,
<code class="language-plaintext highlighter-rouge">GET /mfe-fragments/:remoteId/:fragmentId</code>, for a descriptor: the entry URL, its integrity hash, the protocol and a
state. Never a token. That route runs a <code class="language-plaintext highlighter-rouge">ResolveFragmentMount</code> use case through <code class="language-plaintext highlighter-rouge">run()</code>, which adds three gates to the
page’s checks:</p>

<ol>
  <li><strong>The host’s registry approves the fragment.</strong> It’s listed in code, next to the remote’s audience and scopes, and
it’s checked before any manifest is read.</li>
  <li><strong>The manifest declares it</strong>, with a fragment protocol the host supports.</li>
  <li><strong>The loaded entry exports it</strong>, again with a supported protocol.</li>
</ol>

<p>A manifest can’t add a fragment on its own. From there, the entry loads through the same integrity-checked path as the
page. One entry exports the page and every fragment, so the single-chunk rule and its hash still cover all of the
island’s code. The browser downloads that entry once, however many slots (or the page) ask for it.</p>

<p>A fragment gets its token from the same <code class="language-plaintext highlighter-rouge">POST /mfe-access/:remoteId</code> route, with the remote’s scopes. That means its
token is exactly as powerful as the page’s. That’s acceptable while CRA is read-only. The registry already reserves
per-fragment scopes, checked at boot to be a subset of the remote’s, for the day a remote gains a write scope. There’s
one React Router change here too. On the island’s own page, <code class="language-plaintext highlighter-rouge">shouldRevalidate</code> stopped a token request from re-running
loaders. On the dashboard, nothing did, so the token fetcher now submits with <code class="language-plaintext highlighter-rouge">defaultShouldRevalidate: false</code>, and a token
request re-runs no loader anywhere.</p>

<p>A fragment fails the way the island does, only in a smaller space. If the descriptor can’t be had, the hash doesn’t
match, the mount throws or the fragment’s API call fails, the tile shows a localized notice with <strong>Try again</strong>, and the
page still answers 200. The notice doesn’t announce itself as an alert or steal focus, because it’s one tile on a busy
page. Signed out or without consent, the tile shows a plain message and never redirects. Even the host’s outlet code is
loaded lazily, so if that download fails, only the slot notices.</p>

<p>Deploys needed one more piece of care. The entry’s file name is a hash, so every release replaces it. A dashboard opened
before a deploy may still hold a descriptor that names the old entry. So the remote keeps serving previous releases’
entries from a retained directory, which a <code class="language-plaintext highlighter-rouge">retain</code> step fills in its release pipeline. Manifests are never served from
there. Rollouts go remote first, host second, and <code class="language-plaintext highlighter-rouge">MFE_FRAGMENTS=off</code> turns every slot off without redeploying the host.
A value that isn’t valid also means off.</p>

<p>Finally, every response now carries a Content Security Policy in report-only mode. Its <code class="language-plaintext highlighter-rouge">script-src</code> and <code class="language-plaintext highlighter-rouge">connect-src</code>
come from the registry: <code class="language-plaintext highlighter-rouge">'self'</code> plus each approved remote’s origin. It can’t be enforced yet, because <code class="language-plaintext highlighter-rouge">'unsafe-inline'</code>
stays in <code class="language-plaintext highlighter-rouge">script-src</code> until server-rendered scripts carry nonces. Until then, it reports violations to a host route,
where they’re logged.</p>

<h3 id="an-integration-boundary-not-a-sandbox">An integration boundary, not a sandbox</h3>

<p>One last caveat, and it’s the most important. Once mounted, the island runs with the host origin’s privileges. It runs
in the same document, with the same cookies, the same DOM and the same token route. In the cluster, its server is even
served from the host’s origin, so it’s trusted as well. The mount contract is an <strong>integration</strong> boundary. It is not a
sandbox.</p>

<p>The registry, the origin allow-list, the integrity check and the Content Security Policy (report-only, for now) all stop
<em>unapproved</em> code from running. None of them stops <em>approved</em> code that turns malicious. Fragments widen that exposure:
they run third-party-built code on the account’s busiest page, and they ask for tokens more often. That’s why only
approved remotes from allow-listed origins are registered, and why the data is synthetic for now. A truly untrusted third
party would need an iframe and a different contract.</p>

<p>The island is a deliberate trade. It buys one area its own release schedule, and it pays with a run-time contract, a
token flow and a trust decision that someone has to own. That’s exactly the kind of cost the rest of this post avoids,
which is why it’s an exception and not a pattern.</p>

<h2 id="what-we-gave-up-and-what-we-got">What we gave up, and what we got</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Network boundaries</th>
      <th>Boundaries in the build</th>
      <th>The island (the exception)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Deployables</td>
      <td>one per area, plus composition</td>
      <td>one</td>
      <td>one more: its own build and image</td>
    </tr>
    <tr>
      <td>Moving between areas</td>
      <td>often a full page load</td>
      <td>client-side navigation</td>
      <td>client-side, through the host’s router</td>
    </tr>
    <tr>
      <td>Contracts between areas</td>
      <td>URLs, payloads, shared session, run time</td>
      <td>typed APIs and route keys, build time</td>
      <td><code class="language-plaintext highlighter-rouge">mount(el, ctx)</code> and a manifest, run time</td>
    </tr>
    <tr>
      <td>What enforces the boundaries</td>
      <td>the network</td>
      <td>package exports, a lint script, CODEOWNERS</td>
      <td>contract version, integrity, allow-lists</td>
    </tr>
    <tr>
      <td>Version skew between areas</td>
      <td>something you manage</td>
      <td>can’t happen</td>
      <td>bounded: same major version only</td>
    </tr>
    <tr>
      <td>Releasing an area alone</td>
      <td>yes</td>
      <td>no: one release train</td>
      <td>yes</td>
    </tr>
  </tbody>
</table>

<p>The cost is real. Every team ships together, and one team’s broken build blocks everyone. Being disciplined about
<code class="language-plaintext highlighter-rouge">main</code> and having a fast <code class="language-plaintext highlighter-rouge">check</code> command (typecheck, lint, boundaries, format and unit tests in one go) matter more here
than they would elsewhere. The island is the escape hatch, and the previous section showed what it costs.</p>

<p>What we got back is everything that tends to make split deployments expensive: no per-area platform, no session format
that several independently released apps have to agree on, no composition layer, and one developer experience. The
boundaries didn’t go away. They moved somewhere cheaper to check.</p>

<p>This design is probably <em>not</em> right if <em>most</em> of your teams need independent release cadences. One or two areas can be
islands, but each one adds a run-time contract, a token flow and a trust decision, and a dozen of them means you want a
different architecture. It’s also not right if your teams work in different languages or runtimes on the server, or
if one area needs to scale very differently from the others. In those cases the network is earning its keep.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>The ideas aren’t new: bounded contexts, published languages, anti-corruption layers, ports and adapters, and a
composition root. What made them cheap enough to apply everywhere was the tooling:</p>

<ul>
  <li><strong>Packages and their <code class="language-plaintext highlighter-rouge">exports</code></strong> turn bounded contexts into something Node, Vite and TypeScript enforce.</li>
  <li><strong>Effect’s <code class="language-plaintext highlighter-rouge">R</code> parameter and its layers</strong> turn hexagonal architecture into something the compiler checks, with no
container and no reflection.</li>
  <li><strong>The Effect runtime</strong> owns the process and the HTTP edge: startup, configuration, shutdown, tracing and sessions.</li>
  <li><strong>React Router 8</strong> owns the page: routes from packages, layouts, middleware guards and thin loaders.</li>
  <li><strong>A small bridge</strong> carries one typed request context from the socket, through Effect and React Router, into a use
case and back, and it maps every expected failure exhaustively.</li>
  <li><strong>One session, sliced</strong> per bounded context and per tab, so modules and tabs share a sign-in without sharing state.</li>
  <li><strong>One explicit exception</strong>, an island with its own build and a versioned mount contract, lets the one area that must
ship alone do so without a second router, a second session or a second account. Its fragments even let it lend a
tile to a module’s page without either side importing the other.</li>
</ul>

<p>If you take one thing from this post, I’d make it this: a boundary the compiler checks is cheaper than one the network
enforces, and usually more honest too. When a boundary really does have to move to run time, make it one explicit
exception, and write down exactly what its checks promise and what they don’t.</p>]]></content><author><name>Greg Baker</name></author><category term="architecture" /><category term="domain-driven-design" /><category term="effect-ts" /><category term="react-router" /><category term="typescript" /><summary type="html"><![CDATA[I wanted several teams to work on one citizen-facing web application without stepping on each other. Each team owns one area: sign-in, the citizen’s profile, benefits, jobs, payments, and a dashboard that ties them together. I also wanted one build, one server process and one container image.]]></summary></entry><entry><title type="html">A clean Entra ID on-behalf-of flow in Spring Boot with RestClient</title><link href="https://stn-dts.github.io/2026/03/18/spring-boot-entraid-obo-flow.html" rel="alternate" type="text/html" title="A clean Entra ID on-behalf-of flow in Spring Boot with RestClient" /><published>2026-03-18T00:00:00+00:00</published><updated>2026-03-18T00:00:00+00:00</updated><id>https://stn-dts.github.io/2026/03/18/spring-boot-entraid-obo-flow</id><content type="html" xml:base="https://stn-dts.github.io/2026/03/18/spring-boot-entraid-obo-flow.html"><![CDATA[<p>The OAuth 2.0 on-behalf-of (OBO) flow solves one specific problem. Your API receives a user’s access token, but the
real work happens in another protected API further downstream. You can’t just forward the original bearer token and
hope the next service accepts it, because that token was issued for <em>your</em> API, not the next one. With OAuth, the right
move is to exchange the incoming user token for a new access token that’s issued for the downstream API.</p>

<p>You need this any time the user’s identity has to carry across service boundaries. A gateway calls an orders API, and
the orders API calls a CRM. Or a web app calls a backend, and that backend needs to read from a secure internal data
service using the caller’s delegated permissions (“delegated” means the app acts with the user’s permissions, not its
own). In each case the downstream system should get a token meant for it, not a token minted for something else.</p>

<p>In this post I’ll walk through a Spring Boot pattern that keeps the exchange logic out of your business code. The idea
is simple. Put the OBO exchange behind a customized <code class="language-plaintext highlighter-rouge">RestClient</code>, let Spring Security fetch the token, and add a small
cache so you don’t hit the token endpoint on every downstream request.</p>

<h2 id="what-the-flow-needs-to-do">What the flow needs to do</h2>

<p>At runtime, a request goes through these steps:</p>

<ol>
  <li>A user calls your API with a JWT access token.</li>
  <li>Your application authenticates that token as usual.</li>
  <li>A downstream client inside your API makes an HTTP call through a <code class="language-plaintext highlighter-rouge">RestClient</code>.</li>
  <li>An OAuth2 interceptor asks for an access token for the downstream API.</li>
  <li>A custom OBO provider takes the incoming JWT, sends it to Entra ID with <code class="language-plaintext highlighter-rouge">requested_token_use=on_behalf_of</code>, and gets
back a new access token.</li>
  <li>The <code class="language-plaintext highlighter-rouge">RestClient</code> sends the downstream request with that new token.</li>
</ol>

<p>What I like here is where this happens. Your use case code stays boring: it calls a typed client. The typed client
knows nothing about parsing JWTs or calling token endpoints. The infrastructure layer owns that problem.</p>

<h2 id="why-restclient-is-a-good-fit">Why <code class="language-plaintext highlighter-rouge">RestClient</code> is a good fit</h2>

<p>You could do the OBO exchange in a service class, in a filter, or in a custom HTTP client wrapper that does everything
itself. I don’t like any of those.</p>

<p>Putting it behind <code class="language-plaintext highlighter-rouge">RestClient</code> gives you a cleaner boundary. The application service depends on a downstream port or a
typed client. Authentication stays in the infrastructure layer. A token is only fetched when you actually make a
downstream call, and Spring Security still handles the OAuth2 protocol details.</p>

<p>That separation is why the pattern holds up. The domain code doesn’t know what Entra ID is. The controller doesn’t fetch
tokens by hand. The HTTP client doesn’t contain business logic. Each layer has one job.</p>

<h2 id="how-the-pieces-fit-together">How the pieces fit together</h2>

<p>In a typical hexagonal Spring Boot app (one where business logic talks to the outside world only through interfaces
called ports), the pieces look like this:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">OrderService</code>, or some other application service, calls a downstream port.</li>
  <li><code class="language-plaintext highlighter-rouge">DownstreamApiClient</code> is a small infrastructure wrapper around <code class="language-plaintext highlighter-rouge">RestClient</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">OAuth2ClientHttpRequestInterceptor</code> gets tokens when requests leave the process.</li>
  <li><code class="language-plaintext highlighter-rouge">DownstreamOboTokenProvider</code> teaches Spring Security how to do the Entra ID OBO exchange.</li>
  <li><code class="language-plaintext highlighter-rouge">OboTokenCache</code> reuses exchanged tokens for the same source JWT until either token is close to expiring.</li>
</ul>

<p>This gives you one narrow seam to test and one obvious place to put operational safeguards.</p>

<h2 id="start-with-a-typed-restclient">Start with a typed <code class="language-plaintext highlighter-rouge">RestClient</code></h2>

<p>First, hide the raw HTTP details behind a client that represents the downstream dependency. The business layer should
call methods, not build URLs by hand.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">final</span> <span class="kd">class</span> <span class="nc">DownstreamApiClient</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">RestClient</span> <span class="n">restClient</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">DownstreamApiClient</span><span class="o">(</span>
            <span class="nc">String</span> <span class="n">baseUrl</span><span class="o">,</span>
            <span class="nc">Collection</span><span class="o">&lt;</span><span class="nc">ClientHttpRequestInterceptor</span><span class="o">&gt;</span> <span class="n">interceptors</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">restClient</span> <span class="o">=</span> <span class="nc">RestClient</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
            <span class="o">.</span><span class="na">baseUrl</span><span class="o">(</span><span class="n">baseUrl</span><span class="o">)</span>
            <span class="o">.</span><span class="na">requestInterceptors</span><span class="o">(</span><span class="n">chain</span> <span class="o">-&gt;</span> <span class="n">chain</span><span class="o">.</span><span class="na">addAll</span><span class="o">(</span><span class="n">interceptors</span><span class="o">))</span>
            <span class="o">.</span><span class="na">build</span><span class="o">();</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">OrderSummary</span><span class="o">&gt;</span> <span class="nf">getOrdersForCustomer</span><span class="o">(</span><span class="nc">String</span> <span class="n">customerId</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">restClient</span><span class="o">.</span><span class="na">get</span><span class="o">()</span>
            <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="n">uriBuilder</span> <span class="o">-&gt;</span> <span class="n">uriBuilder</span>
              <span class="o">.</span><span class="na">path</span><span class="o">(</span><span class="s">"/orders"</span><span class="o">)</span>
              <span class="o">.</span><span class="na">queryParam</span><span class="o">(</span><span class="s">"customerId"</span><span class="o">,</span> <span class="n">customerId</span><span class="o">)</span>
              <span class="o">.</span><span class="na">build</span><span class="o">())</span>
            <span class="o">.</span><span class="na">retrieve</span><span class="o">()</span>
            <span class="o">.</span><span class="na">body</span><span class="o">(</span><span class="nc">OrderSummary</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
    <span class="o">}</span>

<span class="o">}</span>
</code></pre></div></div>

<p>This is thin on purpose. It only handles HTTP wiring and turning the response into objects. It knows nothing about
Entra ID. That part comes from the interceptor you pass in.</p>

<h2 id="wire-obo-into-the-restclient-not-your-service-layer">Wire OBO into the <code class="language-plaintext highlighter-rouge">RestClient</code>, not your service layer</h2>

<p>The cleanest place to put all of this together is an auto-configuration or a dedicated configuration class.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@ConfigurationProperties</span><span class="o">(</span><span class="s">"app.downstream"</span><span class="o">)</span>
<span class="kd">public</span> <span class="n">record</span> <span class="nf">DownstreamApiProperties</span><span class="o">(</span>
    <span class="nc">String</span> <span class="n">baseUrl</span><span class="o">,</span>
    <span class="nc">CacheProperties</span> <span class="n">cache</span><span class="o">,</span>
    <span class="nc">OAuthProperties</span> <span class="n">oauth</span>
<span class="o">)</span> <span class="o">{</span>

    <span class="kd">public</span> <span class="n">record</span> <span class="nf">CacheProperties</span><span class="o">(</span><span class="nc">Boolean</span> <span class="n">enabled</span><span class="o">,</span> <span class="nc">Duration</span> <span class="n">expirySkew</span><span class="o">,</span> <span class="nc">Integer</span> <span class="n">maximumSize</span><span class="o">)</span> <span class="o">{}</span>
    <span class="kd">public</span> <span class="n">record</span> <span class="nf">OAuthProperties</span><span class="o">(</span><span class="nc">String</span> <span class="n">clientId</span><span class="o">,</span> <span class="nc">String</span> <span class="n">clientSecret</span><span class="o">,</span> <span class="nc">String</span> <span class="n">tokenUrl</span><span class="o">,</span> <span class="nc">String</span> <span class="n">scope</span><span class="o">)</span> <span class="o">{}</span>

<span class="o">}</span>

<span class="nd">@AutoConfiguration</span>
<span class="nd">@EnableConfigurationProperties</span><span class="o">({</span> <span class="nc">DownstreamApiProperties</span><span class="o">.</span><span class="na">class</span> <span class="o">})</span>
<span class="nd">@ConditionalOnProperty</span><span class="o">(</span><span class="n">prefix</span> <span class="o">=</span> <span class="s">"app.downstream"</span><span class="o">,</span> <span class="n">name</span> <span class="o">=</span> <span class="s">"base-url"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">DownstreamApiAutoConfiguration</span> <span class="o">{</span>

    <span class="nd">@Bean</span>
    <span class="kd">protected</span> <span class="nc">DownstreamApiClient</span> <span class="nf">downstreamApiClient</span><span class="o">(</span>
            <span class="nc">ObjectProvider</span><span class="o">&lt;</span><span class="nc">OboTokenCache</span><span class="o">&gt;</span> <span class="n">cacheProvider</span><span class="o">,</span>
            <span class="nc">DownstreamApiProperties</span> <span class="n">properties</span><span class="o">)</span> <span class="o">{</span>

        <span class="kd">final</span> <span class="kt">var</span> <span class="n">clientRegistration</span> <span class="o">=</span> <span class="nc">ClientRegistration</span>
            <span class="o">.</span><span class="na">withRegistrationId</span><span class="o">(</span><span class="nc">DownstreamOboTokenProvider</span><span class="o">.</span><span class="na">CLIENT_REGISTRATION_ID</span><span class="o">)</span>
            <span class="o">.</span><span class="na">clientId</span><span class="o">(</span><span class="n">requiredOauth</span><span class="o">(</span><span class="n">properties</span><span class="o">).</span><span class="na">clientId</span><span class="o">())</span>
            <span class="o">.</span><span class="na">clientSecret</span><span class="o">(</span><span class="n">requiredOauth</span><span class="o">(</span><span class="n">properties</span><span class="o">).</span><span class="na">clientSecret</span><span class="o">())</span>
            <span class="o">.</span><span class="na">clientAuthenticationMethod</span><span class="o">(</span><span class="nc">ClientAuthenticationMethod</span><span class="o">.</span><span class="na">CLIENT_SECRET_POST</span><span class="o">)</span>
            <span class="o">.</span><span class="na">authorizationGrantType</span><span class="o">(</span><span class="nc">AuthorizationGrantType</span><span class="o">.</span><span class="na">JWT_BEARER</span><span class="o">)</span>
            <span class="o">.</span><span class="na">scope</span><span class="o">(</span><span class="n">requiredOauth</span><span class="o">(</span><span class="n">properties</span><span class="o">).</span><span class="na">scope</span><span class="o">())</span>
            <span class="o">.</span><span class="na">tokenUri</span><span class="o">(</span><span class="n">requiredOauth</span><span class="o">(</span><span class="n">properties</span><span class="o">).</span><span class="na">tokenUrl</span><span class="o">())</span>
            <span class="o">.</span><span class="na">build</span><span class="o">();</span>

        <span class="kd">final</span> <span class="kt">var</span> <span class="n">registrations</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">InMemoryClientRegistrationRepository</span><span class="o">(</span><span class="n">clientRegistration</span><span class="o">);</span>

        <span class="kd">final</span> <span class="kt">var</span> <span class="n">manager</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">AuthorizedClientServiceOAuth2AuthorizedClientManager</span><span class="o">(</span>
            <span class="n">registrations</span><span class="o">,</span>
            <span class="k">new</span> <span class="nf">NoOpAuthorizedClientService</span><span class="o">()</span>
        <span class="o">);</span>

        <span class="n">manager</span><span class="o">.</span><span class="na">setAuthorizedClientProvider</span><span class="o">(</span>
            <span class="k">new</span> <span class="nf">DownstreamOboTokenProvider</span><span class="o">(</span><span class="n">cacheProvider</span><span class="o">.</span><span class="na">getIfAvailable</span><span class="o">())</span>
        <span class="o">);</span>

        <span class="kd">final</span> <span class="kt">var</span> <span class="n">oauth2</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">OAuth2ClientHttpRequestInterceptor</span><span class="o">(</span><span class="n">manager</span><span class="o">);</span>
        <span class="n">oauth2</span><span class="o">.</span><span class="na">setClientRegistrationIdResolver</span><span class="o">(</span><span class="n">_</span> <span class="o">-&gt;</span> <span class="nc">DownstreamOboTokenProvider</span><span class="o">.</span><span class="na">CLIENT_REGISTRATION_ID</span><span class="o">);</span>
        <span class="n">oauth2</span><span class="o">.</span><span class="na">setPrincipalResolver</span><span class="o">(</span><span class="k">new</span> <span class="nc">SecurityContextHolderPrincipalResolver</span><span class="o">());</span>

        <span class="k">return</span> <span class="k">new</span> <span class="nf">DownstreamApiClient</span><span class="o">(</span>
            <span class="n">properties</span><span class="o">.</span><span class="na">baseUrl</span><span class="o">(),</span>
            <span class="nc">List</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="n">oauth2</span><span class="o">)</span>
        <span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">DownstreamApiProperties</span><span class="o">.</span><span class="na">OAuthProperties</span> <span class="nf">requiredOauth</span><span class="o">(</span><span class="nc">DownstreamApiProperties</span> <span class="n">properties</span><span class="o">)</span> <span class="o">{</span>
        <span class="kd">final</span> <span class="kt">var</span> <span class="n">oauth</span> <span class="o">=</span> <span class="n">properties</span><span class="o">.</span><span class="na">oauth</span><span class="o">();</span>

        <span class="k">if</span> <span class="o">(</span><span class="n">oauth</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalStateException</span><span class="o">(</span>
                <span class="s">"Downstream OAuth properties must be configured when app.downstream.base-url is set"</span>
            <span class="o">);</span>
        <span class="o">}</span>

        <span class="kd">final</span> <span class="kt">var</span> <span class="n">missing</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ArrayList</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;();</span>
        <span class="n">addIfMissing</span><span class="o">(</span><span class="n">oauth</span><span class="o">.</span><span class="na">clientId</span><span class="o">(),</span> <span class="s">"app.downstream.oauth.client-id"</span><span class="o">,</span> <span class="n">missing</span><span class="o">);</span>
        <span class="n">addIfMissing</span><span class="o">(</span><span class="n">oauth</span><span class="o">.</span><span class="na">clientSecret</span><span class="o">(),</span> <span class="s">"app.downstream.oauth.client-secret"</span><span class="o">,</span> <span class="n">missing</span><span class="o">);</span>
        <span class="n">addIfMissing</span><span class="o">(</span><span class="n">oauth</span><span class="o">.</span><span class="na">tokenUrl</span><span class="o">(),</span> <span class="s">"app.downstream.oauth.token-url"</span><span class="o">,</span> <span class="n">missing</span><span class="o">);</span>
        <span class="n">addIfMissing</span><span class="o">(</span><span class="n">oauth</span><span class="o">.</span><span class="na">scope</span><span class="o">(),</span> <span class="s">"app.downstream.oauth.scope"</span><span class="o">,</span> <span class="n">missing</span><span class="o">);</span>

        <span class="k">if</span> <span class="o">(!</span><span class="n">missing</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalStateException</span><span class="o">(</span>
                <span class="s">"Downstream OAuth properties must be configured. Missing or blank properties: "</span>
                <span class="o">+</span> <span class="nc">String</span><span class="o">.</span><span class="na">join</span><span class="o">(</span><span class="s">", "</span><span class="o">,</span> <span class="n">missing</span><span class="o">)</span>
            <span class="o">);</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="n">oauth</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">addIfMissing</span><span class="o">(</span><span class="nc">String</span> <span class="n">value</span><span class="o">,</span> <span class="nc">String</span> <span class="n">name</span><span class="o">,</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">&gt;</span> <span class="n">missing</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">value</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="n">value</span><span class="o">.</span><span class="na">isBlank</span><span class="o">())</span> <span class="o">{</span> <span class="n">missing</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="n">name</span><span class="o">);</span> <span class="o">}</span>
    <span class="o">}</span>

<span class="o">}</span>
</code></pre></div></div>

<p>A few details here are worth pointing out.</p>

<p>The client registration uses <code class="language-plaintext highlighter-rouge">AuthorizationGrantType.JWT_BEARER</code>. That’s the grant type Spring Security uses for the
OBO exchange. The <code class="language-plaintext highlighter-rouge">SecurityContextHolderPrincipalResolver</code> tells the interceptor to use the currently authenticated user
for the outgoing request. And the startup validation fails fast, listing exactly which properties are missing. That
sounds minor until you’re debugging a bad deployment at 4 a.m. on a Saturday night.</p>

<p>I also prefer the no-op authorized-client service here. OBO is driven by the current request’s user token, so I want
token reuse to be explicit and limited to the dedicated OBO cache, not hidden inside a generic client store.</p>

<h2 id="get-the-incoming-token-from-the-authenticated-principal">Get the incoming token from the authenticated principal</h2>

<p>After the resource server authenticates the request, the incoming JWT is already in the security context. Don’t dig it
out of the request headers in your service code. Let Spring Security hand it to the authorized-client provider.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">final</span> <span class="kd">class</span> <span class="nc">DownstreamOboTokenProvider</span> <span class="kd">implements</span> <span class="nc">OAuth2AuthorizedClientProvider</span> <span class="o">{</span>

    <span class="kd">public</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">String</span> <span class="no">CLIENT_REGISTRATION_ID</span> <span class="o">=</span> <span class="s">"downstream-api"</span><span class="o">;</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">JwtBearerOAuth2AuthorizedClientProvider</span> <span class="n">delegate</span><span class="o">;</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">OboTokenCache</span> <span class="n">tokenCache</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">DownstreamOboTokenProvider</span><span class="o">(</span><span class="nc">OboTokenCache</span> <span class="n">tokenCache</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">tokenCache</span> <span class="o">=</span> <span class="n">tokenCache</span><span class="o">;</span>

        <span class="kd">final</span> <span class="kt">var</span> <span class="n">messageConverter</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">OAuth2AccessTokenResponseHttpMessageConverter</span><span class="o">();</span>

        <span class="kd">final</span> <span class="kt">var</span> <span class="n">tokenRestClient</span> <span class="o">=</span> <span class="nc">RestClient</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
            <span class="o">.</span><span class="na">configureMessageConverters</span><span class="o">(</span><span class="n">converters</span> <span class="o">-&gt;</span> <span class="n">converters</span><span class="o">.</span><span class="na">addCustomConverter</span><span class="o">(</span><span class="n">messageConverter</span><span class="o">))</span>
            <span class="o">.</span><span class="na">build</span><span class="o">();</span>

        <span class="kd">final</span> <span class="kt">var</span> <span class="n">responseClient</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">RestClientJwtBearerTokenResponseClient</span><span class="o">();</span>
        <span class="n">responseClient</span><span class="o">.</span><span class="na">setRestClient</span><span class="o">(</span><span class="n">tokenRestClient</span><span class="o">);</span>
        <span class="n">responseClient</span><span class="o">.</span><span class="na">setParametersCustomizer</span><span class="o">(</span><span class="n">parameters</span> <span class="o">-&gt;</span>
            <span class="n">parameters</span><span class="o">.</span><span class="na">set</span><span class="o">(</span><span class="s">"requested_token_use"</span><span class="o">,</span> <span class="s">"on_behalf_of"</span><span class="o">)</span>
        <span class="o">);</span>

        <span class="k">this</span><span class="o">.</span><span class="na">delegate</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">JwtBearerOAuth2AuthorizedClientProvider</span><span class="o">();</span>
        <span class="k">this</span><span class="o">.</span><span class="na">delegate</span><span class="o">.</span><span class="na">setAccessTokenResponseClient</span><span class="o">(</span><span class="n">responseClient</span><span class="o">);</span>
        <span class="k">this</span><span class="o">.</span><span class="na">delegate</span><span class="o">.</span><span class="na">setJwtAssertionResolver</span><span class="o">(</span><span class="n">context</span> <span class="o">-&gt;</span>
            <span class="nc">Optional</span><span class="o">.</span><span class="na">ofNullable</span><span class="o">(</span><span class="n">context</span><span class="o">.</span><span class="na">getPrincipal</span><span class="o">())</span>
                <span class="o">.</span><span class="na">filter</span><span class="o">(</span><span class="nc">JwtAuthenticationToken</span><span class="o">.</span><span class="na">class</span><span class="o">::</span><span class="n">isInstance</span><span class="o">)</span>
                <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="nc">JwtAuthenticationToken</span><span class="o">.</span><span class="na">class</span><span class="o">::</span><span class="n">cast</span><span class="o">)</span>
                <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="nl">JwtAuthenticationToken:</span><span class="o">:</span><span class="n">getToken</span><span class="o">)</span>
                <span class="o">.</span><span class="na">orElseThrow</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">IllegalStateException</span><span class="o">(</span>
                    <span class="s">"No JwtAuthenticationToken found in authorization context for downstream OBO exchange"</span>
                <span class="o">))</span>
        <span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">OAuth2AuthorizedClient</span> <span class="nf">authorize</span><span class="o">(</span><span class="nc">OAuth2AuthorizationContext</span> <span class="n">context</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">Objects</span><span class="o">.</span><span class="na">requireNonNull</span><span class="o">(</span><span class="n">context</span><span class="o">,</span> <span class="s">"context cannot be null"</span><span class="o">);</span>

        <span class="k">if</span> <span class="o">(!</span><span class="no">CLIENT_REGISTRATION_ID</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">context</span><span class="o">.</span><span class="na">getClientRegistration</span><span class="o">().</span><span class="na">getRegistrationId</span><span class="o">()))</span> <span class="o">{</span>
            <span class="k">return</span> <span class="kc">null</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">if</span> <span class="o">(!(</span><span class="n">context</span><span class="o">.</span><span class="na">getPrincipal</span><span class="o">()</span> <span class="k">instanceof</span> <span class="nc">JwtAuthenticationToken</span> <span class="n">authentication</span><span class="o">))</span> <span class="o">{</span>
            <span class="k">return</span> <span class="kc">null</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">if</span> <span class="o">(</span><span class="n">tokenCache</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">return</span> <span class="n">tokenCache</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="n">authentication</span><span class="o">.</span><span class="na">getToken</span><span class="o">(),</span> <span class="o">()</span> <span class="o">-&gt;</span> <span class="n">delegate</span><span class="o">.</span><span class="na">authorize</span><span class="o">(</span><span class="n">context</span><span class="o">));</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="n">delegate</span><span class="o">.</span><span class="na">authorize</span><span class="o">(</span><span class="n">context</span><span class="o">);</span>
    <span class="o">}</span>

<span class="o">}</span>
</code></pre></div></div>

<p>Two things make this safe to run in production.</p>

<p>The first is the <code class="language-plaintext highlighter-rouge">jwtAssertionResolver</code>. It makes sure the token sent in the exchange (the “assertion”) comes from the
authenticated <code class="language-plaintext highlighter-rouge">JwtAuthenticationToken</code>, not from some ad hoc thread-local or request attribute. If there’s no
JWT-backed principal, the flow fails with a clear error.</p>

<p>The second is the parameter customizer. Entra ID expects <code class="language-plaintext highlighter-rouge">requested_token_use=on_behalf_of</code> on the token request. If
you leave it out, you aren’t doing OBO. You’re just sending a JWT bearer token request and hoping Entra guesses what you
meant.</p>

<h2 id="what-entra-id-actually-receives">What Entra ID actually receives</h2>

<p>With this setup, the token request body looks roughly like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
requested_token_use=on_behalf_of
assertion=&lt;incoming user access token&gt;
client_id=&lt;your confidential client id&gt;
client_secret=&lt;your client secret&gt;
scope=api://downstream-api/.default
</code></pre></div></div>

<p>Each field has a job. The assertion is the original user token. The client ID and secret identify your API as the
confidential client making the exchange. The scope points at the downstream API, not the current one. And the
on-behalf-of flag tells Entra ID what kind of exchange you want.</p>

<p>If the downstream API permissions are wrong, or the app registrations aren’t set up to trust each other, this request
fails. That’s a configuration problem, not a code problem.</p>

<h2 id="add-a-cache-or-youll-exchange-too-often">Add a cache or you’ll exchange too often</h2>

<p>Without a cache, every downstream call can trigger a token exchange. It works, but it’s noisy, slower than it needs to
be, and puts extra load on the token endpoint.</p>

<p>The cache I like is deliberately narrow. It stores exchanged authorized clients keyed by the source JWT’s issuer, its
subject, and a SHA-256 fingerprint (hash) of the raw token. Each entry expires at whichever comes first: the source
JWT’s expiry or the exchanged access token’s expiry. A small skew is subtracted from that time before the entry counts
as reusable.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">final</span> <span class="kd">class</span> <span class="nc">OboTokenCache</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="n">record</span> <span class="nf">CacheKey</span><span class="o">(</span><span class="nc">String</span> <span class="n">issuer</span><span class="o">,</span> <span class="nc">String</span> <span class="n">subject</span><span class="o">,</span> <span class="nc">String</span> <span class="n">tokenFingerprint</span><span class="o">)</span> <span class="o">{}</span>
    <span class="kd">private</span> <span class="n">record</span> <span class="nf">CachedAuthorizedClient</span><span class="o">(</span><span class="nc">OAuth2AuthorizedClient</span> <span class="n">client</span><span class="o">,</span> <span class="nc">Instant</span> <span class="n">expiresAt</span><span class="o">)</span> <span class="o">{}</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">Cache</span> <span class="n">cache</span><span class="o">;</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">Duration</span> <span class="n">expirySkew</span><span class="o">;</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">Clock</span> <span class="n">clock</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nc">OAuth2AuthorizedClient</span> <span class="nf">get</span><span class="o">(</span>
            <span class="nc">Jwt</span> <span class="n">jwt</span><span class="o">,</span>
            <span class="nc">Supplier</span><span class="o">&lt;</span><span class="nc">OAuth2AuthorizedClient</span><span class="o">&gt;</span> <span class="n">authorizedClientProvider</span><span class="o">)</span> <span class="o">{</span>
        <span class="kd">final</span> <span class="kt">var</span> <span class="n">sourceExpiresAt</span> <span class="o">=</span> <span class="nc">Optional</span><span class="o">.</span><span class="na">ofNullable</span><span class="o">(</span><span class="n">jwt</span><span class="o">.</span><span class="na">getExpiresAt</span><span class="o">())</span>
            <span class="o">.</span><span class="na">orElseGet</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="nc">Instant</span><span class="o">.</span><span class="na">now</span><span class="o">(</span><span class="n">clock</span><span class="o">).</span><span class="na">plus</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofHours</span><span class="o">(</span><span class="mi">1</span><span class="o">)));</span>

        <span class="k">return</span> <span class="nf">cacheKey</span><span class="o">(</span><span class="n">jwt</span><span class="o">)</span>
            <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="n">key</span> <span class="o">-&gt;</span> <span class="n">getOrExchange</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="n">sourceExpiresAt</span><span class="o">,</span> <span class="n">authorizedClientProvider</span><span class="o">))</span>
            <span class="o">.</span><span class="na">orElseGet</span><span class="o">(</span><span class="n">authorizedClientProvider</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">OAuth2AuthorizedClient</span> <span class="nf">getOrExchange</span><span class="o">(</span>
            <span class="nc">CacheKey</span> <span class="n">key</span><span class="o">,</span>
            <span class="nc">Instant</span> <span class="n">sourceExpiresAt</span><span class="o">,</span>
            <span class="nc">Supplier</span><span class="o">&lt;</span><span class="nc">OAuth2AuthorizedClient</span><span class="o">&gt;</span> <span class="n">authorizedClientProvider</span><span class="o">)</span> <span class="o">{</span>
        <span class="kd">final</span> <span class="kt">var</span> <span class="n">now</span> <span class="o">=</span> <span class="nc">Instant</span><span class="o">.</span><span class="na">now</span><span class="o">(</span><span class="n">clock</span><span class="o">);</span>
        <span class="kd">final</span> <span class="kt">var</span> <span class="n">cached</span> <span class="o">=</span> <span class="n">getCachedEntry</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="n">now</span><span class="o">).</span><span class="na">map</span><span class="o">(</span><span class="nl">CachedAuthorizedClient:</span><span class="o">:</span><span class="n">client</span><span class="o">);</span>

        <span class="k">if</span> <span class="o">(</span><span class="n">cached</span><span class="o">.</span><span class="na">isPresent</span><span class="o">())</span> <span class="o">{</span> <span class="k">return</span> <span class="n">cached</span><span class="o">.</span><span class="na">get</span><span class="o">();</span> <span class="o">}</span>

        <span class="k">return</span> <span class="nf">getCachedEntry</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="nc">Instant</span><span class="o">.</span><span class="na">now</span><span class="o">(</span><span class="n">clock</span><span class="o">))</span>
            <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="nl">CachedAuthorizedClient:</span><span class="o">:</span><span class="n">client</span><span class="o">)</span>
            <span class="o">.</span><span class="na">orElseGet</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="o">{</span>
                <span class="kd">final</span> <span class="kt">var</span> <span class="n">authorizedClient</span> <span class="o">=</span> <span class="n">authorizedClientProvider</span><span class="o">.</span><span class="na">get</span><span class="o">();</span>
                <span class="n">cacheAuthorizedClient</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="n">sourceExpiresAt</span><span class="o">,</span> <span class="n">authorizedClient</span><span class="o">);</span>
                <span class="k">return</span> <span class="n">authorizedClient</span><span class="o">;</span>
            <span class="o">});</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">cacheAuthorizedClient</span><span class="o">(</span>
            <span class="nc">CacheKey</span> <span class="n">key</span><span class="o">,</span>
            <span class="nc">Instant</span> <span class="n">sourceExpiresAt</span><span class="o">,</span>
            <span class="nc">OAuth2AuthorizedClient</span> <span class="n">authorizedClient</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">authorizedClient</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="n">authorizedClient</span><span class="o">.</span><span class="na">getAccessToken</span><span class="o">()</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">return</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="kd">final</span> <span class="kt">var</span> <span class="n">accessTokenExpiresAt</span> <span class="o">=</span> <span class="n">authorizedClient</span><span class="o">.</span><span class="na">getAccessToken</span><span class="o">().</span><span class="na">getExpiresAt</span><span class="o">();</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">accessTokenExpiresAt</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">return</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="kd">final</span> <span class="kt">var</span> <span class="n">earliestExpiresAt</span> <span class="o">=</span> <span class="n">sourceExpiresAt</span><span class="o">.</span><span class="na">isBefore</span><span class="o">(</span><span class="n">accessTokenExpiresAt</span><span class="o">)</span>
            <span class="o">?</span> <span class="n">sourceExpiresAt</span>
            <span class="o">:</span> <span class="n">accessTokenExpiresAt</span><span class="o">;</span>

        <span class="kd">final</span> <span class="kt">var</span> <span class="n">effectiveExpiresAt</span> <span class="o">=</span> <span class="n">earliestExpiresAt</span><span class="o">.</span><span class="na">minus</span><span class="o">(</span><span class="n">expirySkew</span><span class="o">);</span>

        <span class="k">if</span> <span class="o">(</span><span class="n">effectiveExpiresAt</span><span class="o">.</span><span class="na">isBefore</span><span class="o">(</span><span class="nc">Instant</span><span class="o">.</span><span class="na">now</span><span class="o">(</span><span class="n">clock</span><span class="o">)))</span> <span class="o">{</span>
            <span class="k">return</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="n">cache</span><span class="o">.</span><span class="na">put</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="k">new</span> <span class="nc">CachedAuthorizedClient</span><span class="o">(</span><span class="n">authorizedClient</span><span class="o">,</span> <span class="n">effectiveExpiresAt</span><span class="o">));</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">Optional</span><span class="o">&lt;</span><span class="nc">CacheKey</span><span class="o">&gt;</span> <span class="nf">cacheKey</span><span class="o">(</span><span class="nc">Jwt</span> <span class="n">jwt</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(!</span><span class="nc">StringUtils</span><span class="o">.</span><span class="na">hasText</span><span class="o">(</span><span class="n">jwt</span><span class="o">.</span><span class="na">getTokenValue</span><span class="o">()))</span> <span class="o">{</span>
            <span class="k">return</span> <span class="nc">Optional</span><span class="o">.</span><span class="na">empty</span><span class="o">();</span>
        <span class="o">}</span>

        <span class="k">if</span> <span class="o">(!</span><span class="nc">StringUtils</span><span class="o">.</span><span class="na">hasText</span><span class="o">(</span><span class="n">jwt</span><span class="o">.</span><span class="na">getSubject</span><span class="o">()))</span> <span class="o">{</span>
            <span class="k">return</span> <span class="nc">Optional</span><span class="o">.</span><span class="na">empty</span><span class="o">();</span>
        <span class="o">}</span>

        <span class="kd">final</span> <span class="kt">var</span> <span class="n">issuer</span> <span class="o">=</span> <span class="nc">Optional</span><span class="o">.</span><span class="na">ofNullable</span><span class="o">(</span><span class="n">jwt</span><span class="o">.</span><span class="na">getIssuer</span><span class="o">()).</span><span class="na">map</span><span class="o">(</span><span class="nl">Object:</span><span class="o">:</span><span class="n">toString</span><span class="o">).</span><span class="na">orElse</span><span class="o">(</span><span class="s">""</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(!</span><span class="nc">StringUtils</span><span class="o">.</span><span class="na">hasText</span><span class="o">(</span><span class="n">issuer</span><span class="o">))</span> <span class="o">{</span>
            <span class="k">return</span> <span class="nc">Optional</span><span class="o">.</span><span class="na">empty</span><span class="o">();</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="nc">Optional</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="k">new</span> <span class="nc">CacheKey</span><span class="o">(</span><span class="n">issuer</span><span class="o">,</span> <span class="n">jwt</span><span class="o">.</span><span class="na">getSubject</span><span class="o">(),</span> <span class="n">sha256</span><span class="o">(</span><span class="n">jwt</span><span class="o">.</span><span class="na">getTokenValue</span><span class="o">())));</span>
    <span class="o">}</span>

<span class="o">}</span>
</code></pre></div></div>

<p>There are three practical ideas in that code.</p>

<p>First, the cache key includes a fingerprint of the raw incoming token. When the user’s token rotates, the old cache
entry stops being used. That’s what you want.</p>

<p>Second, the expiry is based on whichever token expires first. Once the source JWT has expired, the cached OBO token
shouldn’t be reused, even if its own expiry is later.</p>

<p>Third, the second cache lookup right before the exchange is deliberate. It cuts down on duplicate token exchanges when
several requests arrive at the same time.</p>

<h2 id="log-enough-to-debug-but-never-log-secrets">Log enough to debug, but never log secrets</h2>

<p>You only notice the token exchange code when it breaks, so good logging pays off. Here’s what I recommend:</p>

<ul>
  <li>Log when a provider skips authorization because the registration ID doesn’t match.</li>
  <li>Log when the principal isn’t a <code class="language-plaintext highlighter-rouge">JwtAuthenticationToken</code>.</li>
  <li>Log cache reuse, miss, recheck hit, exchange, expiry, and store events.</li>
  <li>Include stable identifiers like the issuer and subject.</li>
  <li>Never log the raw JWT, the access token, the client secret, or the token request body.</li>
</ul>

<p>Get this wrong and you either have nothing to go on when production fails, or you’ve written exactly the material an
incident review will ask why you logged.</p>

<h2 id="make-configuration-explicit">Make configuration explicit</h2>

<p>Keep the required Entra settings together and validate them at startup.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">app</span><span class="pi">:</span>
  <span class="na">downstream</span><span class="pi">:</span>
    <span class="na">base-url</span><span class="pi">:</span> <span class="s">https://api.example.internal</span>
    <span class="na">api-key</span><span class="pi">:</span> <span class="s">00000000-0000-0000-0000-000000000000</span>
    <span class="na">cache</span><span class="pi">:</span>
      <span class="na">enabled</span><span class="pi">:</span> <span class="no">true</span>
      <span class="na">expiry-skew</span><span class="pi">:</span> <span class="s">30s</span>
      <span class="na">maximum-size</span><span class="pi">:</span> <span class="m">1000</span>
    <span class="na">oauth</span><span class="pi">:</span>
      <span class="na">client-id</span><span class="pi">:</span> <span class="s">00000000-0000-0000-0000-000000000000</span>
      <span class="na">client-secret</span><span class="pi">:</span> <span class="s">00000000-0000-0000-0000-000000000000</span>
      <span class="na">token-url</span><span class="pi">:</span> <span class="s">https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/v2.0/token</span>
      <span class="na">scope</span><span class="pi">:</span> <span class="s">api://downstream-api/User.Read.All</span>
</code></pre></div></div>

<p>Name the properties however you like. What matters is what they mean. <code class="language-plaintext highlighter-rouge">client-id</code> and <code class="language-plaintext highlighter-rouge">client-secret</code> identify your
confidential client. <code class="language-plaintext highlighter-rouge">token-url</code> points at your tenant’s Entra token endpoint. <code class="language-plaintext highlighter-rouge">scope</code> targets the downstream API. The
cache settings control how aggressively exchanged tokens get reused.</p>

<p>If your application makes both OBO calls and service-account calls, use separate client registrations for them, even if
they point at the same downstream API. They authenticate in different ways, and your code should make that clear.</p>

<h2 id="the-service-that-uses-the-client-stays-simple">The service that uses the client stays simple</h2>

<p>Once the client is wired up, your application code doesn’t carry any OAuth2 boilerplate.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">final</span> <span class="kd">class</span> <span class="nc">OrderService</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">DownstreamApiClient</span> <span class="n">downstreamApiClient</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">OrderService</span><span class="o">(</span><span class="nc">DownstreamApiClient</span> <span class="n">downstreamApiClient</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">downstreamApiClient</span> <span class="o">=</span> <span class="nc">Objects</span><span class="o">.</span><span class="na">requireNonNull</span><span class="o">(</span>
            <span class="n">downstreamApiClient</span><span class="o">,</span>
            <span class="s">"downstreamApiClient cannot be null"</span>
        <span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">OrderSummary</span><span class="o">&gt;</span> <span class="nf">getOrdersForCustomer</span><span class="o">(</span><span class="nc">String</span> <span class="n">customerId</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">downstreamApiClient</span><span class="o">.</span><span class="na">getOrdersForCustomer</span><span class="o">(</span><span class="n">customerId</span><span class="o">);</span>
    <span class="o">}</span>

<span class="o">}</span>
</code></pre></div></div>

<p>That’s the payoff. The OBO flow is there, but it doesn’t leak across the whole application.</p>

<h2 id="gotchas">Gotchas</h2>

<p>These are the things that usually trip people up.</p>

<h3 id="the-scope-must-belong-to-the-downstream-api">The scope must belong to the downstream API</h3>

<p>The OBO token request isn’t asking for more access to the current API. It’s asking for a token for the next API. If
your scope value is wrong, the code can look perfect and still fail.</p>

<h3 id="requested_token_useon_behalf_of-is-not-optional"><code class="language-plaintext highlighter-rouge">requested_token_use=on_behalf_of</code> is not optional</h3>

<p>Spring Security gets you most of the way, but Entra ID still expects this parameter on the token request. Add it
explicitly.</p>

<h3 id="fail-fast-on-missing-properties">Fail fast on missing properties</h3>

<p>Don’t let the application start with a base URL and half an OAuth2 config. It’s much better to crash at startup with a
message that lists the missing property names.</p>

<h3 id="cache-against-both-token-lifetimes">Cache against both token lifetimes</h3>

<p>If you only look at the downstream access token’s expiry, you can end up reusing an exchanged token after the user’s
original token should no longer be allowed through.</p>

<h3 id="add-a-little-expiry-skew">Add a little expiry skew</h3>

<p>Subtracting 30 seconds from the expiry is a cheap way to avoid failures in the middle of a request. Clock drift, thread
scheduling, or a slow network hop can otherwise catch a token right as it expires.</p>

<h3 id="test-the-raw-token-request-not-just-the-happy-path">Test the raw token request, not just the happy path</h3>

<p>One of the most useful tests for this pattern checks that the token endpoint receives:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer</code></li>
  <li><code class="language-plaintext highlighter-rouge">requested_token_use=on_behalf_of</code></li>
  <li>the incoming JWT as <code class="language-plaintext highlighter-rouge">assertion</code></li>
  <li>the expected confidential-client credentials</li>
</ul>

<p>That test catches the kind of regression that otherwise only shows up after you deploy.</p>

<h2 id="why-i-like-this-approach">Why I like this approach</h2>

<p>It’s strict where it matters. The business layer never touches token plumbing. The infrastructure layer only
customizes what it has to. Startup validation catches broken config early. The cache cuts down on repeated exchanges
without trying to be clever, and the logging is useful without leaking credentials.</p>

<p>Most of the moving parts already exist in Spring Security. The trick is knowing where to put the Entra-specific pieces
so the rest of the application stays boring. That’s usually a good sign you picked the right design.</p>

<h2 id="appendix-calling-the-same-downstream-api-with-a-service-account">Appendix: calling the same downstream API with a service account</h2>

<p>Sometimes the same downstream API needs to be called in two different ways. Calls made as part of a user’s request
should go through OBO, so the downstream API sees the caller’s delegated identity. Calls made by the system itself
should use the client credentials flow, because there’s no user involved.</p>

<p>This is common in real systems. A normal API request might need OBO, while a nightly reconciliation job against the
same downstream API runs as a service account. The target API is the same, but how you authenticate is not.</p>

<p>The clean way to model this is with two client registrations and two typed clients, even if they share a base URL.</p>

<h3 id="add-a-second-provider-for-client-credentials">Add a second provider for client credentials</h3>

<p>The service-account side is much simpler, because there’s no incoming JWT to exchange.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">final</span> <span class="kd">class</span> <span class="nc">ServiceAccountAuthorizedClientProvider</span> <span class="kd">implements</span> <span class="nc">OAuth2AuthorizedClientProvider</span> <span class="o">{</span>

        <span class="kd">public</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">String</span> <span class="no">CLIENT_REGISTRATION_ID</span> <span class="o">=</span> <span class="s">"downstream-api-service-account"</span><span class="o">;</span>

        <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ClientCredentialsOAuth2AuthorizedClientProvider</span> <span class="n">delegate</span> <span class="o">=</span>
            <span class="k">new</span> <span class="nf">ClientCredentialsOAuth2AuthorizedClientProvider</span><span class="o">();</span>

        <span class="nd">@Override</span>
        <span class="kd">public</span> <span class="nc">OAuth2AuthorizedClient</span> <span class="nf">authorize</span><span class="o">(</span><span class="nc">OAuth2AuthorizationContext</span> <span class="n">context</span><span class="o">)</span> <span class="o">{</span>
            <span class="nc">Objects</span><span class="o">.</span><span class="na">requireNonNull</span><span class="o">(</span><span class="n">context</span><span class="o">,</span> <span class="s">"context cannot be null"</span><span class="o">);</span>

            <span class="kd">final</span> <span class="kt">var</span> <span class="n">registrationId</span> <span class="o">=</span> <span class="n">context</span><span class="o">.</span><span class="na">getClientRegistration</span><span class="o">().</span><span class="na">getRegistrationId</span><span class="o">();</span>

            <span class="k">if</span> <span class="o">(!</span><span class="no">CLIENT_REGISTRATION_ID</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">registrationId</span><span class="o">))</span> <span class="o">{</span>
                    <span class="k">return</span> <span class="kc">null</span><span class="o">;</span>
            <span class="o">}</span>

            <span class="k">return</span> <span class="n">delegate</span><span class="o">.</span><span class="na">authorize</span><span class="o">(</span><span class="n">context</span><span class="o">);</span>
        <span class="o">}</span>

<span class="o">}</span>
</code></pre></div></div>

<p>There’s no <code class="language-plaintext highlighter-rouge">JwtAuthenticationToken</code> requirement here because the client credentials flow isn’t tied to a user. Spring
Security just needs a client registration with <code class="language-plaintext highlighter-rouge">AuthorizationGrantType.CLIENT_CREDENTIALS</code>.</p>

<h3 id="register-a-second-restclient">Register a second <code class="language-plaintext highlighter-rouge">RestClient</code></h3>

<p>Don’t overload the OBO client with a second mode. Make the split explicit.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Bean</span>
<span class="nc">ServiceAccountDownstreamApiClient</span> <span class="nf">serviceAccountDownstreamApiClient</span><span class="o">(</span><span class="nc">DownstreamApiProperties</span> <span class="n">properties</span><span class="o">)</span> <span class="o">{</span>
    <span class="kd">final</span> <span class="kt">var</span> <span class="n">clientRegistration</span> <span class="o">=</span> <span class="nc">ClientRegistration</span>
        <span class="o">.</span><span class="na">withRegistrationId</span><span class="o">(</span><span class="nc">ServiceAccountAuthorizedClientProvider</span><span class="o">.</span><span class="na">CLIENT_REGISTRATION_ID</span><span class="o">)</span>
        <span class="o">.</span><span class="na">clientId</span><span class="o">(</span><span class="n">requiredOauth</span><span class="o">(</span><span class="n">properties</span><span class="o">).</span><span class="na">clientId</span><span class="o">())</span>
        <span class="o">.</span><span class="na">clientSecret</span><span class="o">(</span><span class="n">requiredOauth</span><span class="o">(</span><span class="n">properties</span><span class="o">).</span><span class="na">clientSecret</span><span class="o">())</span>
        <span class="o">.</span><span class="na">clientAuthenticationMethod</span><span class="o">(</span><span class="nc">ClientAuthenticationMethod</span><span class="o">.</span><span class="na">CLIENT_SECRET_POST</span><span class="o">)</span>
        <span class="o">.</span><span class="na">authorizationGrantType</span><span class="o">(</span><span class="nc">AuthorizationGrantType</span><span class="o">.</span><span class="na">CLIENT_CREDENTIALS</span><span class="o">)</span>
        <span class="o">.</span><span class="na">scope</span><span class="o">(</span><span class="n">requiredOauth</span><span class="o">(</span><span class="n">properties</span><span class="o">).</span><span class="na">scope</span><span class="o">())</span>
        <span class="o">.</span><span class="na">tokenUri</span><span class="o">(</span><span class="n">requiredOauth</span><span class="o">(</span><span class="n">properties</span><span class="o">).</span><span class="na">tokenUrl</span><span class="o">())</span>
        <span class="o">.</span><span class="na">build</span><span class="o">();</span>

    <span class="kd">final</span> <span class="kt">var</span> <span class="n">registrations</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">InMemoryClientRegistrationRepository</span><span class="o">(</span><span class="n">clientRegistration</span><span class="o">);</span>

    <span class="kd">final</span> <span class="kt">var</span> <span class="n">manager</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">AuthorizedClientServiceOAuth2AuthorizedClientManager</span><span class="o">(</span>
        <span class="n">registrations</span><span class="o">,</span>
        <span class="k">new</span> <span class="nf">InMemoryOAuth2AuthorizedClientService</span><span class="o">(</span><span class="n">registrations</span><span class="o">)</span>
    <span class="o">);</span>

    <span class="n">manager</span><span class="o">.</span><span class="na">setAuthorizedClientProvider</span><span class="o">(</span><span class="k">new</span> <span class="nc">ServiceAccountAuthorizedClientProvider</span><span class="o">());</span>

    <span class="kd">final</span> <span class="kt">var</span> <span class="n">oauth2</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">OAuth2ClientHttpRequestInterceptor</span><span class="o">(</span><span class="n">manager</span><span class="o">);</span>
    <span class="n">oauth2</span><span class="o">.</span><span class="na">setClientRegistrationIdResolver</span><span class="o">(</span><span class="n">_</span> <span class="o">-&gt;</span> <span class="nc">ServiceAccountAuthorizedClientProvider</span><span class="o">.</span><span class="na">CLIENT_REGISTRATION_ID</span><span class="o">);</span>

    <span class="k">return</span> <span class="k">new</span> <span class="nf">ServiceAccountDownstreamApiClient</span><span class="o">(</span>
        <span class="n">properties</span><span class="o">.</span><span class="na">baseUrl</span><span class="o">(),</span>
        <span class="nc">List</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="n">oauth2</span><span class="o">)</span>
    <span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>This looks a lot like the OBO setup, but it differs in two important ways. The grant type is <code class="language-plaintext highlighter-rouge">CLIENT_CREDENTIALS</code>, not
<code class="language-plaintext highlighter-rouge">JWT_BEARER</code>. And the authorized-client service can be the normal in-memory one, because there’s no per-user token
exchange cache to manage.</p>

<h3 id="keep-the-api-shape-the-same-not-the-auth-path">Keep the API shape the same, not the auth path</h3>

<p>To keep your application code readable, expose two typed clients with the same downstream operations.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">final</span> <span class="kd">class</span> <span class="nc">ServiceAccountDownstreamApiClient</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">RestClient</span> <span class="n">restClient</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">ServiceAccountDownstreamApiClient</span><span class="o">(</span>
            <span class="nc">String</span> <span class="n">baseUrl</span><span class="o">,</span>
            <span class="nc">Collection</span><span class="o">&lt;</span><span class="nc">ClientHttpRequestInterceptor</span><span class="o">&gt;</span> <span class="n">interceptors</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">restClient</span> <span class="o">=</span> <span class="nc">RestClient</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
            <span class="o">.</span><span class="na">baseUrl</span><span class="o">(</span><span class="n">baseUrl</span><span class="o">)</span>
            <span class="o">.</span><span class="na">requestInterceptors</span><span class="o">(</span><span class="n">chain</span> <span class="o">-&gt;</span> <span class="n">chain</span><span class="o">.</span><span class="na">addAll</span><span class="o">(</span><span class="n">interceptors</span><span class="o">))</span>
            <span class="o">.</span><span class="na">build</span><span class="o">();</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">OrderSummary</span><span class="o">&gt;</span> <span class="nf">getOrdersForCustomer</span><span class="o">(</span><span class="nc">String</span> <span class="n">customerId</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">restClient</span><span class="o">.</span><span class="na">get</span><span class="o">()</span>
            <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="n">uriBuilder</span> <span class="o">-&gt;</span> <span class="n">uriBuilder</span>
                <span class="o">.</span><span class="na">path</span><span class="o">(</span><span class="s">"/orders"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">queryParam</span><span class="o">(</span><span class="s">"customerId"</span><span class="o">,</span> <span class="n">customerId</span><span class="o">)</span>
                <span class="o">.</span><span class="na">build</span><span class="o">())</span>
            <span class="o">.</span><span class="na">retrieve</span><span class="o">()</span>
            <span class="o">.</span><span class="na">body</span><span class="o">(</span><span class="nc">OrderSummary</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
    <span class="o">}</span>

<span class="o">}</span>
</code></pre></div></div>

<p>Yes, that duplicates a little transport code. I still prefer it. If one client acts as the user and the other acts as
a service account, I want that difference to be visible in the types and in dependency injection. It makes code reviews
easier, and it makes an accidental change in privileges much harder to miss.</p>

<h3 id="split-the-configuration-on-purpose">Split the configuration on purpose</h3>

<p>If both auth modes call the same downstream API, keep the shared settings together and split the auth settings by
registration.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">app</span><span class="pi">:</span>
    <span class="na">downstream</span><span class="pi">:</span>
        <span class="na">base-url</span><span class="pi">:</span> <span class="s">https://api.example.internal</span>
        <span class="na">obo</span><span class="pi">:</span>
            <span class="na">client-id</span><span class="pi">:</span> <span class="s">00000000-0000-0000-0000-000000000000</span>
            <span class="na">client-secret</span><span class="pi">:</span> <span class="s">00000000-0000-0000-0000-000000000000</span>
            <span class="na">token-url</span><span class="pi">:</span> <span class="s">https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/v2.0/token</span>
            <span class="na">scope</span><span class="pi">:</span> <span class="s">api://downstream-api/user_impersonation</span>
        <span class="na">service-account</span><span class="pi">:</span>
            <span class="na">client-id</span><span class="pi">:</span> <span class="s">FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF</span>
            <span class="na">client-secret</span><span class="pi">:</span> <span class="s">FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF</span>
            <span class="na">token-url</span><span class="pi">:</span> <span class="s">https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/v2.0/token</span>
            <span class="na">scope</span><span class="pi">:</span> <span class="s">api://downstream-api/.default</span>
</code></pre></div></div>

<p>Sometimes those scopes are the same and sometimes they aren’t. The point isn’t to force one shape on every deployment.
It’s to make the difference obvious, because delegated permissions and application permissions are different security
contracts.</p>

<h3 id="choose-the-client-based-on-why-the-call-exists">Choose the client based on why the call exists</h3>

<p>This last part is about architecture, not mechanics. If the call is part of a user’s request and the downstream system
should see the user’s delegated identity, use the OBO client. If it’s a background job, a scheduled sync, or some other
system-to-system action, use the service-account client.</p>

<p>Don’t pick based on convenience. Pick based on who the downstream API should see as the caller. If the answer is “the
user,” use OBO. If it’s “this application,” use client credentials.</p>]]></content><author><name>Greg Baker</name></author><category term="spring-boot" /><category term="java" /><category term="entra-id" /><category term="oauth2" /><category term="security" /><summary type="html"><![CDATA[The OAuth 2.0 on-behalf-of (OBO) flow solves one specific problem. Your API receives a user’s access token, but the real work happens in another protected API further downstream. You can’t just forward the original bearer token and hope the next service accepts it, because that token was issued for your API, not the next one. With OAuth, the right move is to exchange the incoming user token for a new access token that’s issued for the downstream API.]]></summary></entry><entry><title type="html">Entra ID app registration guidance for locked-down tenants</title><link href="https://stn-dts.github.io/2026/03/02/entra-id-app-registration-trust-chain-guidance.html" rel="alternate" type="text/html" title="Entra ID app registration guidance for locked-down tenants" /><published>2026-03-02T00:00:00+00:00</published><updated>2026-03-02T00:00:00+00:00</updated><id>https://stn-dts.github.io/2026/03/02/entra-id-app-registration-trust-chain-guidance</id><content type="html" xml:base="https://stn-dts.github.io/2026/03/02/entra-id-app-registration-trust-chain-guidance.html"><![CDATA[<p>If your tenant is tightly locked down, setting up app registrations can feel like guessing a lock combination. You wire
up something that looks right, sign in, and hit a consent or permission error you didn’t expect.</p>

<p>I’ve run into this again and again when connecting a frontend to a protected API in a tenant where users can’t grant
delegated permissions (letting an app act on their behalf) unless the API has pre-authorized that client app. Add
tenant-wide admin approval for Microsoft Graph permissions on top of that, and even a basic sign-in can show warnings.</p>

<p>This post is practical advice for that kind of tenant. I describe the steps in the Azure Portal, but they’re the same if
you automate with infrastructure as code (IaC).</p>

<h2 id="the-trust-chain">The trust chain</h2>

<p>Most teams think of this as two apps: a frontend app and an API app.</p>

<p>In a restricted tenant there’s a third piece you can’t skip: a pre-authorization link from the API to the client.
Without it, the client can ask for scopes, but in practice users still can’t grant it access.</p>

<p>Here’s the mental model I use. Every piece has to be in place:</p>

<ul>
  <li>The API app defines its scopes and app roles.</li>
  <li>The client app lists the API permissions it needs.</li>
  <li>The API app pre-authorizes that specific client for specific scopes.</li>
  <li>An admin grants consent wherever tenant policy requires it, for example for Microsoft Graph permissions.</li>
</ul>

<p>If any one of these is missing, sign-in usually fails later on, and it looks like a bug in your code rather than a
configuration problem.</p>

<h2 id="setting-it-up">Setting it up</h2>

<p>Do things in this order. It saves a lot of trial and error.</p>

<h3 id="1-define-the-api-first">1. Define the API first</h3>

<p>Create the API app registration and publish the permissions it offers:</p>

<ul>
  <li>Set the Application ID URI for the API.</li>
  <li>Define delegated scopes, with clear names and consent text.</li>
  <li>Define app roles if you need application permissions.</li>
  <li>Make sure the scope values are final before you wire up any clients.</li>
</ul>

<p>The API comes first because every client’s configuration depends on its scope IDs and values staying the same.</p>

<h3 id="2-register-each-client-with-its-permissions-spelled-out">2. Register each client with its permissions spelled out</h3>

<p>For each client app (for example, your frontend and your CLI):</p>

<ul>
  <li>Add the API scopes it needs as required permissions.</li>
  <li>Add the Microsoft Graph permissions it needs.</li>
  <li>Set up redirect URIs and auth settings for that type of client.</li>
</ul>

<p>Keep this boring and explicit. “We’ll sort it out at consent time” is not a plan.</p>

<h3 id="3-pre-authorize-clients-on-the-api">3. Pre-authorize clients on the API</h3>

<p>This is the step people forget in restricted tenants. For each client that calls the API:</p>

<ul>
  <li>Open the API app registration.</li>
  <li>Add the client app as an authorized client.</li>
  <li>Select the API scopes that client is allowed to request.</li>
</ul>

<p>If your tenant blocks user delegation without pre-authorization, this is the step that turns a setup that “looks right”
into one that actually works.</p>

<h3 id="4-get-admin-consent-on-purpose">4. Get admin consent on purpose</h3>

<p>In many organizations, Microsoft Graph permissions need admin approval, even delegated ones. So expect consent warnings
at sign-in until an administrator grants consent for the Graph scopes you request.</p>

<p>Plan this as its own step. Don’t leave it as an afterthought.</p>

<h2 id="tenant-quirks-to-expect">Tenant quirks to expect</h2>

<h3 id="delegated-permissions-fail-without-pre-authorization">Delegated permissions fail without pre-authorization</h3>

<p>You’ll see this when sign-in works but calls to the API fail with consent or “insufficient privileges” errors.</p>

<p>Usually the client asked for a valid API scope, but the API app hasn’t pre-authorized that client. To fix it, add the
client as a pre-authorized client in the API app registration, then double-check that the exact scopes the client
requests are selected.</p>

<h3 id="graph-delegated-permissions-still-need-admin-approval">Graph delegated permissions still need admin approval</h3>

<p>You’ll see warnings or admin approval prompts at sign-in, even for basic sign-in scopes.</p>

<p>This happens because tenant policy requires admin consent for Microsoft Graph permissions. Have an admin grant consent
for the Graph permissions the client requests. Keep your list of Graph scopes short so it’s easy for them to review.</p>

<h3 id="scope-names-match-but-ids-differ-between-environments">Scope names match, but IDs differ between environments</h3>

<p>Non-prod works, but another environment fails with what looks like a consent mismatch.</p>

<p>Usually the client is pointed at the wrong API registration, or it’s using scope IDs copied from somewhere else. Check
the API app ID, the client app ID, and the target scopes in each environment. Then set up the pre-authorization link
again for the right client and API pair.</p>

<h2 id="quick-troubleshooting-table">Quick troubleshooting table</h2>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>Most likely cause</th>
      <th>Where to fix</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>User can sign in but API call returns insufficient privileges</td>
      <td>Client is not pre-authorized on API</td>
      <td>API app registration -&gt; Authorized client applications</td>
    </tr>
    <tr>
      <td>Consent prompt keeps appearing for Graph scopes</td>
      <td>Admin consent not granted per tenant policy</td>
      <td>Entra admin consent workflow for Graph permissions</td>
    </tr>
    <tr>
      <td>Azure Portal frontend works but Swagger UI fails (or vice versa)</td>
      <td>One client app was configured, the other was not</td>
      <td>Compare required permissions and pre-authorization per client</td>
    </tr>
    <tr>
      <td>Everything looks configured but still fails in one env</td>
      <td>IDs or scopes copied from another environment</td>
      <td>Re-validate app IDs, scope values, and consent in that environment</td>
    </tr>
  </tbody>
</table>

<h2 id="habits-that-help">Habits that help</h2>

<p>A few team habits make this a lot less painful. Treat API scopes as a contract and be careful when you change them. Keep
a short checklist for every new client app. Make pre-authorization a required item in reviews. And talk to your tenant
admins early about Graph consent.</p>

<p>None of these steps is hard on its own. The pain comes from missing one link in the chain and only finding out at
sign-in.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>In a tenant with strict consent policy, setting up app registrations is less about flipping the right switches and more
about building a complete trust chain.</p>

<p>Define the API scopes first. Give each client its permissions explicitly. Pre-authorize clients on the API. Plan for
Graph admin consent from day one. Do it in that order and most of the guesswork goes away, and developers get a setup
that behaves the same way every time.</p>]]></content><author><name>Greg Baker</name></author><category term="entra-id" /><category term="azure" /><category term="app-registration" /><category term="authentication" /><category term="oauth2" /><summary type="html"><![CDATA[If your tenant is tightly locked down, setting up app registrations can feel like guessing a lock combination. You wire up something that looks right, sign in, and hit a consent or permission error you didn’t expect.]]></summary></entry><entry><title type="html">Using k3d on Ubuntu 24.04 with rootless Podman</title><link href="https://stn-dts.github.io/2025/11/19/using-k3d-on-ubuntu-2404-with-rootless-podman.html" rel="alternate" type="text/html" title="Using k3d on Ubuntu 24.04 with rootless Podman" /><published>2025-11-19T00:00:00+00:00</published><updated>2025-11-19T00:00:00+00:00</updated><id>https://stn-dts.github.io/2025/11/19/using-k3d-on-ubuntu-2404-with-rootless-podman</id><content type="html" xml:base="https://stn-dts.github.io/2025/11/19/using-k3d-on-ubuntu-2404-with-rootless-podman.html"><![CDATA[<p>k3d runs a Kubernetes cluster on your machine by starting k3s (a small Kubernetes distribution) inside containers. It’s
built for Docker, but it also works with rootless Podman, meaning Podman running as your normal user instead of root.
On Ubuntu 24.04 you need to do a bit of setup first: let your user manage more cgroup resources, raise the kernel’s
file watcher limits, and turn on the Podman socket so k3d can talk to Podman as if it were Docker.</p>

<h2 id="what-you-need">What you need</h2>

<ul>
  <li>Podman, set up to run rootless</li>
  <li>k3d (you can install it with <code class="language-plaintext highlighter-rouge">curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash</code>)</li>
  <li>kubectl, to work with the cluster once it’s running</li>
</ul>

<h2 id="1-let-your-user-control-cpu-cpuset-and-io">1. Let your user control CPU, cpuset, and I/O</h2>

<p>Linux uses cgroups (control groups) to limit how much CPU, memory, and so on a group of processes can use. By default,
systemd only hands a few of these controls to regular users. Kubernetes needs more of them, so we have to tell systemd
to delegate them to user sessions.</p>

<p>Create <code class="language-plaintext highlighter-rouge">/etc/systemd/system/user@.service.d/delegate.conf</code> (you’ll need <code class="language-plaintext highlighter-rouge">sudo</code>) with this content:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[Service]</span>
<span class="py">Delegate</span><span class="p">=</span><span class="s">cpu cpuset io memory pids</span>
</code></pre></div></div>

<p>Then reload systemd:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>systemctl daemon-reload
</code></pre></div></div>

<p>This doesn’t apply to your current session. Log out and back in (or reboot) before going further.</p>

<h2 id="2-turn-on-the-podman-socket">2. Turn on the Podman socket</h2>

<p>k3d talks to its container engine through the Docker API. Podman can provide that same API through a socket, but it’s
off by default. Turn it on with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl <span class="nt">--user</span> <span class="nb">enable</span> <span class="nt">--now</span> podman.socket
</code></pre></div></div>

<h2 id="3-tell-k3d-where-the-socket-is">3. Tell k3d where the socket is</h2>

<p>Next, set two environment variables. The k3d CLI reads <code class="language-plaintext highlighter-rouge">DOCKER_HOST</code> to find the API. <code class="language-plaintext highlighter-rouge">DOCKER_SOCK</code> is the plain file
path to the socket, which some scripts and tools expect instead.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">DOCKER_HOST</span><span class="o">=</span><span class="s2">"unix://</span><span class="nv">$XDG_RUNTIME_DIR</span><span class="s2">/podman/podman.sock"</span>
<span class="nb">export </span><span class="nv">DOCKER_SOCK</span><span class="o">=</span><span class="nv">$XDG_RUNTIME_DIR</span>/podman/podman.sock
</code></pre></div></div>

<p>These only last for the current shell. To have them set every time you log in, add both lines to your shell profile,
such as <code class="language-plaintext highlighter-rouge">~/.bashrc</code> or <code class="language-plaintext highlighter-rouge">~/.zshrc</code>.</p>

<h2 id="4-raise-the-inotify-limits">4. Raise the <code class="language-plaintext highlighter-rouge">inotify</code> limits</h2>

<p><code class="language-plaintext highlighter-rouge">inotify</code> is the Linux feature that lets programs watch files for changes. Each Kubernetes node uses a lot of these
watchers, and when you run several nodes (a server plus a few agents) as rootless containers on one machine, they all
come out of the same per-user limit.</p>

<p>With the default limits, the kubelet’s <code class="language-plaintext highlighter-rouge">cAdvisor</code> (the part that collects container stats) quickly runs out and crashes
with <code class="language-plaintext highlighter-rouge">inotify_init: too many open files</code>. It does this quietly. The agents never finish starting, and <code class="language-plaintext highlighter-rouge">k3d</code> just hangs
forever waiting for the cluster to become “Ready”. This one is easy to miss.</p>

<p>To fix it for good, create <code class="language-plaintext highlighter-rouge">/etc/sysctl.d/99-k3d.conf</code> (again with <code class="language-plaintext highlighter-rouge">sudo</code>):</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="py">fs.inotify.max_user_instances</span><span class="p">=</span><span class="s">8192</span>
<span class="py">fs.inotify.max_user_watches</span><span class="p">=</span><span class="s">524288</span>
</code></pre></div></div>

<p>Then apply the new limits right away, without rebooting:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>sysctl <span class="nt">-p</span> /etc/sysctl.d/99-k3d.conf
</code></pre></div></div>

<h2 id="5-create-the-cluster">5. Create the cluster</h2>

<p>Now create the cluster. Rootless Podman runs everything inside a user namespace, where the kubelet doesn’t have the
host-level privileges it normally expects. The <code class="language-plaintext highlighter-rouge">KubeletInUserNamespace</code> feature gate tells the kubelet about this so it
doesn’t crash. We pass it to both the server and the agents:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>k3d cluster create <span class="nt">--servers</span> 1 <span class="nt">--agents</span> 3 <span class="nt">--k3s-arg</span> <span class="s1">'--kubelet-arg=feature-gates=KubeletInUserNamespace=true@server:*'</span> <span class="nt">--k3s-arg</span> <span class="s1">'--kubelet-arg=feature-gates=KubeletInUserNamespace=true@agent:*'</span>
</code></pre></div></div>

<p>To check that it worked:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>k3d cluster list
kubectl get nodes
</code></pre></div></div>

<h2 id="troubleshooting">Troubleshooting</h2>

<h3 id="apparmor-and-user-namespaces">AppArmor and user namespaces</h3>

<p>Ubuntu 24.04 restricts unprivileged user namespaces, and that can stop rootless Podman from creating containers. If you
see errors about user namespaces, AppArmor is the likely cause.</p>

<p>As a temporary workaround, you can turn the restriction off:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>sysctl <span class="nt">-w</span> kernel.apparmor_restrict_unprivileged_userns<span class="o">=</span>0
</code></pre></div></div>

<p>For a permanent fix, use a version of Podman that ships the AppArmor profiles it needs, or write a profile that allows
<code class="language-plaintext highlighter-rouge">userns</code>.</p>

<h3 id="cgroup-version">cgroup version</h3>

<p>This setup needs cgroup v2. That’s the default on Ubuntu 24.04, but you can check with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>podman info | <span class="nb">grep</span> <span class="s2">"cgroup version"</span>
</code></pre></div></div>

<p>You should see <code class="language-plaintext highlighter-rouge">cgroup version: v2</code>.</p>]]></content><author><name>Greg Baker</name></author><category term="kubernetes" /><category term="k3d" /><category term="podman" /><category term="ubuntu" /><category term="linux" /><summary type="html"><![CDATA[k3d runs a Kubernetes cluster on your machine by starting k3s (a small Kubernetes distribution) inside containers. It’s built for Docker, but it also works with rootless Podman, meaning Podman running as your normal user instead of root. On Ubuntu 24.04 you need to do a bit of setup first: let your user manage more cgroup resources, raise the kernel’s file watcher limits, and turn on the Podman socket so k3d can talk to Podman as if it were Docker.]]></summary></entry><entry><title type="html">A Case for Invariant Domains</title><link href="https://stn-dts.github.io/2025/11/06/a-case-for-invariant-domains.html" rel="alternate" type="text/html" title="A Case for Invariant Domains" /><published>2025-11-06T00:00:00+00:00</published><updated>2025-11-06T00:00:00+00:00</updated><id>https://stn-dts.github.io/2025/11/06/a-case-for-invariant-domains</id><content type="html" xml:base="https://stn-dts.github.io/2025/11/06/a-case-for-invariant-domains.html"><![CDATA[<p>For the last ten years or so I’ve watched (and, honestly, helped spread) a habit I don’t like: anemic domain objects.
These are classes that look like domain models but don’t do anything. They hold data and that’s it. Because nothing stops
them from holding bad data, everyone who uses them ends up writing their own sanity checks, and those checks get
scattered all over the codebase.</p>

<p>In this post I want to argue for the opposite: rich domain models that protect their own rules. I’ll lean on what Martin
Fowler, Microsoft, and Amazon have said and done along the way.</p>

<h2 id="whats-wrong-with-anemic-domain-objects">What’s wrong with anemic domain objects</h2>

<p>An anemic domain object is usually what you get when someone takes “separation of concerns” a step too far. The data
lives in one class and the behavior lives somewhere else. The domain class ends up as a bag of getters and setters, and
all the business rules, validation, and calculations move into service classes that push that data around.</p>

<p>Martin Fowler called this out back in 2003 in his article “Anemic Domain Model”. His point was that it works against
encapsulation, the basic object-oriented idea of keeping data and the code that changes it together. An anemic model is
really procedural code wearing an object costume. You pay the cost of building a domain model and get none of the
payoff.</p>

<p>In practice this causes a few problems.</p>

<p>Validation gets scattered. If the object doesn’t enforce its own rules, every caller has to check things itself. Those
checks drift apart over time and become hard to maintain.</p>

<p>Objects can be in an invalid state. Nothing prevents a half-built or nonsensical object from existing, so calling code
always has to be on guard.</p>

<p>The code stops describing the business. The model doesn’t capture what the domain actually means, so the code is harder
to read and reason about.</p>

<p>Services get bloated. All the domain logic piles up in services, which ties the business rules tightly to the
application flow.</p>

<h2 id="putting-the-rules-back-in-the-object">Putting the rules back in the object</h2>

<p>A rich domain model puts the behavior inside the domain objects themselves. Those objects enforce <em>invariants</em>: rules
about their state that must always be true. If an object never lets itself break an invariant, it can never end up in
an invalid state.</p>

<p>Here’s a simple <code class="language-plaintext highlighter-rouge">BankAccount</code>:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">BankAccount</span> <span class="o">{</span>

	<span class="kd">private</span> <span class="nc">BigDecimal</span> <span class="n">balance</span><span class="o">;</span>
	<span class="kd">private</span> <span class="kt">boolean</span> <span class="n">frozen</span><span class="o">;</span>

	<span class="kd">public</span> <span class="nf">BankAccount</span><span class="o">(</span><span class="nc">BigDecimal</span> <span class="n">initialBalance</span><span class="o">)</span> <span class="o">{</span>
		<span class="k">if</span> <span class="o">(</span><span class="n">initialBalance</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalArgumentException</span><span class="o">(</span><span class="s">"Initial balance cannot be null"</span><span class="o">);</span>
		<span class="o">}</span>

		<span class="k">if</span> <span class="o">(</span><span class="n">initialBalance</span><span class="o">.</span><span class="na">compareTo</span><span class="o">(</span><span class="nc">BigDecimal</span><span class="o">.</span><span class="na">ZERO</span><span class="o">)</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalArgumentException</span><span class="o">(</span><span class="s">"Initial balance cannot be negative"</span><span class="o">);</span>
		<span class="o">}</span>

		<span class="k">this</span><span class="o">.</span><span class="na">balance</span> <span class="o">=</span> <span class="n">initialBalance</span><span class="o">;</span>
		<span class="k">this</span><span class="o">.</span><span class="na">frozen</span> <span class="o">=</span> <span class="kc">false</span><span class="o">;</span>
	<span class="o">}</span>

	<span class="kd">public</span> <span class="kt">void</span> <span class="nf">deposit</span><span class="o">(</span><span class="nc">BigDecimal</span> <span class="n">amount</span><span class="o">)</span> <span class="o">{</span>
		<span class="k">if</span> <span class="o">(</span><span class="n">amount</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalArgumentException</span><span class="o">(</span><span class="s">"Deposit amount cannot be null"</span><span class="o">);</span>
		<span class="o">}</span>

		<span class="k">if</span> <span class="o">(</span><span class="n">amount</span><span class="o">.</span><span class="na">compareTo</span><span class="o">(</span><span class="nc">BigDecimal</span><span class="o">.</span><span class="na">ZERO</span><span class="o">)</span> <span class="o">&lt;=</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalArgumentException</span><span class="o">(</span><span class="s">"Deposit amount must be positive"</span><span class="o">);</span>
		<span class="o">}</span>

		<span class="k">if</span> <span class="o">(</span><span class="n">frozen</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalStateException</span><span class="o">(</span><span class="s">"Cannot deposit to frozen account"</span><span class="o">);</span>
		<span class="o">}</span>

		<span class="k">this</span><span class="o">.</span><span class="na">balance</span> <span class="o">=</span> <span class="k">this</span><span class="o">.</span><span class="na">balance</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="n">amount</span><span class="o">);</span>
	<span class="o">}</span>

	<span class="kd">public</span> <span class="kt">void</span> <span class="nf">withdraw</span><span class="o">(</span><span class="nc">BigDecimal</span> <span class="n">amount</span><span class="o">)</span> <span class="o">{</span>
		<span class="k">if</span> <span class="o">(</span><span class="n">amount</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalArgumentException</span><span class="o">(</span><span class="s">"Withdrawal amount cannot be null"</span><span class="o">);</span>
		<span class="o">}</span>

		<span class="k">if</span> <span class="o">(</span><span class="n">amount</span><span class="o">.</span><span class="na">compareTo</span><span class="o">(</span><span class="nc">BigDecimal</span><span class="o">.</span><span class="na">ZERO</span><span class="o">)</span> <span class="o">&lt;=</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalArgumentException</span><span class="o">(</span><span class="s">"Withdrawal amount must be positive"</span><span class="o">);</span>
		<span class="o">}</span>

		<span class="k">if</span> <span class="o">(</span><span class="n">frozen</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalStateException</span><span class="o">(</span><span class="s">"Cannot withdraw from frozen account"</span><span class="o">);</span>
		<span class="o">}</span>

		<span class="k">if</span> <span class="o">(</span><span class="k">this</span><span class="o">.</span><span class="na">balance</span><span class="o">.</span><span class="na">compareTo</span><span class="o">(</span><span class="n">amount</span><span class="o">)</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalStateException</span><span class="o">(</span><span class="s">"Insufficient funds"</span><span class="o">);</span>
		<span class="o">}</span>

		<span class="k">this</span><span class="o">.</span><span class="na">balance</span> <span class="o">=</span> <span class="k">this</span><span class="o">.</span><span class="na">balance</span><span class="o">.</span><span class="na">subtract</span><span class="o">(</span><span class="n">amount</span><span class="o">);</span>
	<span class="o">}</span>

	<span class="kd">public</span> <span class="kt">void</span> <span class="nf">freeze</span><span class="o">()</span> <span class="o">{</span>
		<span class="k">this</span><span class="o">.</span><span class="na">frozen</span> <span class="o">=</span> <span class="kc">true</span><span class="o">;</span>
	<span class="o">}</span>

	<span class="kd">public</span> <span class="nc">BigDecimal</span> <span class="nf">getBalance</span><span class="o">()</span> <span class="o">{</span>
		<span class="k">return</span> <span class="n">balance</span><span class="o">;</span>
	<span class="o">}</span>

<span class="o">}</span>
</code></pre></div></div>

<p>This class enforces a handful of rules:</p>

<ul>
  <li>The balance can’t be negative.</li>
  <li>The initial balance and any amount passed in can’t be null.</li>
  <li>Deposits and withdrawals must be positive.</li>
  <li>You can’t deposit to or withdraw from a frozen account.</li>
  <li>You can’t withdraw more than the balance, so no overdrafts.</li>
</ul>

<p>All of those rules live inside the class. There’s no way to create an account in a bad state or change it into one. So
any code that gets a <code class="language-plaintext highlighter-rouge">BankAccount</code> can trust that it’s valid without checking.</p>

<h2 id="value-objects">Value objects</h2>

<p>A good way to push invariants down even further is with <em>value objects</em>. A value object is a small, immutable type
that’s defined by its value rather than by an identity, like an amount of money or an email address. Instead of passing
raw <code class="language-plaintext highlighter-rouge">BigDecimal</code> or <code class="language-plaintext highlighter-rouge">String</code> values into your entities, you wrap them in a type that checks its own rules.</p>

<p>For example, instead of passing a raw <code class="language-plaintext highlighter-rouge">BigDecimal</code> to <code class="language-plaintext highlighter-rouge">deposit</code>, we could use a <code class="language-plaintext highlighter-rouge">Money</code> value object:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="n">record</span> <span class="nf">Money</span><span class="o">(</span><span class="nc">BigDecimal</span> <span class="n">amount</span><span class="o">)</span> <span class="o">{</span>
	<span class="kd">public</span> <span class="nc">Money</span> <span class="o">{</span>
		<span class="k">if</span> <span class="o">(</span><span class="n">amount</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="n">amount</span><span class="o">.</span><span class="na">compareTo</span><span class="o">(</span><span class="nc">BigDecimal</span><span class="o">.</span><span class="na">ZERO</span><span class="o">)</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalArgumentException</span><span class="o">(</span><span class="s">"Money amount cannot be negative"</span><span class="o">);</span>
		<span class="o">}</span>
	<span class="o">}</span>

	<span class="kd">public</span> <span class="nc">Money</span> <span class="nf">add</span><span class="o">(</span><span class="nc">Money</span> <span class="n">other</span><span class="o">)</span> <span class="o">{</span>
		<span class="k">return</span> <span class="k">new</span> <span class="nf">Money</span><span class="o">(</span><span class="k">this</span><span class="o">.</span><span class="na">amount</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="n">other</span><span class="o">.</span><span class="na">amount</span><span class="o">));</span>
	<span class="o">}</span>
	<span class="c1">// ... other operations</span>
<span class="o">}</span>
</code></pre></div></div>

<p>This makes <code class="language-plaintext highlighter-rouge">BankAccount</code> simpler. It no longer has to check for null or negative amounts itself, because a <code class="language-plaintext highlighter-rouge">Money</code> can’t
be created with one.</p>

<h2 id="what-others-have-said">What others have said</h2>

<p>This is a core idea in Domain-Driven Design (DDD), from Eric Evans’ well-known book of the same name. Evans says the
domain model is the “heart of business software”, and that it should carry real behavior and strict invariants.</p>

<p>Microsoft’s .NET architecture guidance says the same thing. Its docs on DDD-oriented microservices warn against anemic
domain models and say domain entities should hold data <em>plus</em> behavior. Microsoft recommends Plain Old CLR Objects
(POCOs), which are plain classes with no dependency on frameworks or infrastructure, that still contain the domain
logic.</p>

<p>Amazon has used DDD for a long time to manage the complexity of its large microservice architecture. We can’t see their
internal code, but their focus on bounded contexts (clear boundaries around each part of the domain) and on domain
expertise suggests they build rich domains that enforce their own business rules at the core.</p>

<h2 id="what-you-get-out-of-it">What you get out of it</h2>

<p>Moving the rules into the domain pays off in a few ways.</p>

<p>Bad data stops spreading. If an invalid object can’t exist, it can’t leak into the rest of the system and cause bugs
somewhere far away.</p>

<p>Changes get easier. Business rules live in one obvious place and are written down clearly, so changing them is less
risky.</p>

<p>Testing gets simpler. You can test a domain object on its own, and the invariants tell you exactly what to test.</p>

<p>The code matches the business. When the code reads like the domain, it’s easier for developers and domain experts to
talk about it.</p>

<p>Services get thinner. They coordinate work instead of holding business logic, which keeps the design more modular.</p>

<h2 id="the-hard-parts">The hard parts</h2>

<p>Switching to rich domain models isn’t free. If you’re used to anemic models, it can take a while to get a feel for
where logic belongs.</p>

<h3 id="persistence">Persistence</h3>

<p>The most common pushback is about Object-Relational Mappers (ORMs) like Hibernate or Entity Framework. ORMs load rows
from the database into objects, and many of them want a public no-argument constructor and public setters to do it.
That undercuts encapsulation, because now anyone can build an empty object or change its fields.</p>

<p>There are a few ways around this:</p>

<ul>
  <li>Use private constructors. Most modern ORMs can work with private or protected constructors and set fields directly,
so your public API stays clean.</li>
  <li>Add a mapping layer. Keep a separate “dumb” persistence object for the database and map it to and from your rich
domain entity. This lets your domain logic change without being tied to your database schema.</li>
  <li>Use the Memento pattern. The object hands out a snapshot of its state for saving, without exposing any setters.</li>
</ul>

<h3 id="other-things-that-help">Other things that help</h3>

<ul>
  <li>Start small. Add invariants to new classes, or to classes you’re already refactoring.</li>
  <li>Use aggregate roots (a DDD idea: one entity that controls access to a group of related objects) to manage complex
object graphs.</li>
  <li>Use domain events to announce state changes.</li>
  <li>Use property-based testing, which throws lots of generated inputs at your code, to check that invariants hold.</li>
</ul>

<h2 id="wrapping-up">Wrapping up</h2>

<p>Anemic domain objects have given us fragile systems that are hard to maintain, with validation code spread everywhere.
If we build rich domain models with strong invariants instead, the code can actually describe the business while keeping
the data valid.</p>

<p>Fowler, Microsoft, and Amazon show this isn’t just theory. It works at scale. As our systems keep getting more complex,
invariant domains give us more reliable, maintainable, and readable code. It’s time to bring encapsulation back and let
our domain models do more than hold data.</p>]]></content><author><name>Greg Baker</name></author><category term="software-engineering" /><category term="domain-driven-design" /><category term="ddd" /><summary type="html"><![CDATA[For the last ten years or so I’ve watched (and, honestly, helped spread) a habit I don’t like: anemic domain objects. These are classes that look like domain models but don’t do anything. They hold data and that’s it. Because nothing stops them from holding bad data, everyone who uses them ends up writing their own sanity checks, and those checks get scattered all over the codebase.]]></summary></entry><entry><title type="html">Building Spring Boot container images with Podman</title><link href="https://stn-dts.github.io/2025/11/05/building-spring-boot-container-images-with-podman.html" rel="alternate" type="text/html" title="Building Spring Boot container images with Podman" /><published>2025-11-05T00:00:00+00:00</published><updated>2025-11-05T00:00:00+00:00</updated><id>https://stn-dts.github.io/2025/11/05/building-spring-boot-container-images-with-podman</id><content type="html" xml:base="https://stn-dts.github.io/2025/11/05/building-spring-boot-container-images-with-podman.html"><![CDATA[<p>Spring Boot can turn your app into a container image for you with the <code class="language-plaintext highlighter-rouge">spring-boot:build-image</code> Maven goal (or
<code class="language-plaintext highlighter-rouge">bootBuildImage</code> in Gradle). Out of the box it expects Docker. If you’d rather use Podman, it works fine, but you need to
set up three things first. This post walks through them.</p>

<p>You’ll need Podman installed and working. If you haven’t used it before, Podman is a container engine that doesn’t need
a background daemon. It understands the same commands and image formats as Docker, so most Docker tooling can talk to it.</p>

<h2 id="1-let-the-builder-reach-the-host">1. Let the builder reach the host</h2>

<p>Spring Boot builds the image with Paketo buildpacks, and the buildpack runs <em>inside</em> a container. That container needs to
reach the Podman socket on your machine. To allow it, set <code class="language-plaintext highlighter-rouge">bindHostToBuilder</code> to <code class="language-plaintext highlighter-rouge">true</code> in the Spring Boot plugin
config.</p>

<p>For Maven, in <code class="language-plaintext highlighter-rouge">pom.xml</code>:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;plugin&gt;</span>
  <span class="nt">&lt;groupId&gt;</span>org.springframework.boot<span class="nt">&lt;/groupId&gt;</span>
  <span class="nt">&lt;artifactId&gt;</span>spring-boot-maven-plugin<span class="nt">&lt;/artifactId&gt;</span>
  <span class="nt">&lt;configuration&gt;</span>
    <span class="nt">&lt;docker&gt;</span>
      <span class="nt">&lt;bindHostToBuilder&gt;</span>true<span class="nt">&lt;/bindHostToBuilder&gt;</span>
    <span class="nt">&lt;/docker&gt;</span>
  <span class="nt">&lt;/configuration&gt;</span>
<span class="nt">&lt;/plugin&gt;</span>
</code></pre></div></div>

<p>For Gradle, in <code class="language-plaintext highlighter-rouge">build.gradle</code>:</p>

<div class="language-groovy highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">tasks</span><span class="o">.</span><span class="na">named</span><span class="o">(</span><span class="s2">"bootBuildImage"</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">docker</span> <span class="o">{</span>
        <span class="n">bindHostToBuilder</span> <span class="o">=</span> <span class="kc">true</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="2-turn-on-the-podman-socket">2. Turn on the Podman socket</h2>

<p>Docker runs as a system-wide daemon that tools talk to through a socket file. Podman can run rootless (as your normal
user, without root), so instead it gives each user their own socket. That socket is off by default. Turn it on with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl <span class="nt">--user</span> <span class="nb">enable</span> <span class="nt">--now</span> podman.socket
</code></pre></div></div>

<p>This starts the socket now and every time you log in. It lives at <code class="language-plaintext highlighter-rouge">/run/user/${UID}/podman/podman.sock</code>, where <code class="language-plaintext highlighter-rouge">${UID}</code>
is your user ID (usually <code class="language-plaintext highlighter-rouge">1000</code>).</p>

<h2 id="3-point-the-build-at-podman">3. Point the build at Podman</h2>

<p>The build-image goal talks to Docker, but it decides <em>where</em> Docker is by reading the <code class="language-plaintext highlighter-rouge">DOCKER_HOST</code> environment
variable. Set that to the Podman socket and the build uses Podman instead.</p>

<p>With Maven:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">DOCKER_HOST</span><span class="o">=</span>unix:///run/user/<span class="k">${</span><span class="nv">UID</span><span class="k">}</span>/podman/podman.sock mvn spring-boot:build-image <span class="nt">-Dmaven</span>.test.skip<span class="o">=</span><span class="nb">true</span>
</code></pre></div></div>

<p>With Gradle:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">DOCKER_HOST</span><span class="o">=</span>unix:///run/user/<span class="k">${</span><span class="nv">UID</span><span class="k">}</span>/podman/podman.sock ./gradlew bootBuildImage <span class="nt">-x</span> <span class="nb">test</span>
</code></pre></div></div>

<p>Skipping the tests isn’t required. It just makes the build faster if you’ve already run them.</p>

<h2 id="running-the-image">Running the image</h2>

<p>When the build finishes, the image is stored in Podman’s local image storage. You can see it with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>podman images
</code></pre></div></div>

<p>And run it with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>podman run <span class="nt">--interactive</span> <span class="nt">--rm</span> <span class="nt">--tty</span> <span class="nt">--network</span> host your-app-name:latest
</code></pre></div></div>

<h2 id="troubleshooting-and-tips">Troubleshooting and tips</h2>

<h3 id="selinux-on-fedora-or-rhel">SELinux on Fedora or RHEL</h3>

<p>On systems with SELinux turned on, such as Fedora and RHEL, you might get “permission denied” errors when the builder
tries to use the socket. You may need to make sure the socket has the right SELinux label. To check whether SELinux is
the problem at all, you can temporarily switch it to permissive mode and try again.</p>

<h3 id="slow-builds">Slow builds</h3>

<p>Podman works best with the <code class="language-plaintext highlighter-rouge">overlay</code> storage driver. If your builds are very slow, check which driver you’re using:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>podman info <span class="nt">--format</span> <span class="s1">'{{.Store.GraphDriverName}}'</span>
</code></pre></div></div>

<p>If it says <code class="language-plaintext highlighter-rouge">vfs</code>, set up <code class="language-plaintext highlighter-rouge">fuse-overlayfs</code> instead. It’s much faster.</p>

<h3 id="changing-the-image-name">Changing the image name</h3>

<p>By default the image is named after your project’s artifact ID and version. You can change that in the plugin config.</p>

<p>Maven:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;configuration&gt;</span>
  <span class="nt">&lt;docker&gt;</span>
    <span class="nt">&lt;bindHostToBuilder&gt;</span>true<span class="nt">&lt;/bindHostToBuilder&gt;</span>
  <span class="nt">&lt;/docker&gt;</span>
  <span class="nt">&lt;image&gt;</span>
    <span class="nt">&lt;name&gt;</span>my-registry.com/my-org/${project.artifactId}:${project.version}<span class="nt">&lt;/name&gt;</span>
  <span class="nt">&lt;/image&gt;</span>
<span class="nt">&lt;/configuration&gt;</span>
</code></pre></div></div>

<p>Gradle:</p>

<div class="language-groovy highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">bootBuildImage</span> <span class="o">{</span>
    <span class="n">docker</span> <span class="o">{</span>
        <span class="n">bindHostToBuilder</span> <span class="o">=</span> <span class="kc">true</span>
    <span class="o">}</span>
    <span class="n">imageName</span> <span class="o">=</span> <span class="s2">"my-registry.com/my-org/${project.name}:${project.version}"</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="common-errors">Common errors</h3>

<p>If the build says the socket isn’t found, check that the socket file actually exists at the path you gave in
<code class="language-plaintext highlighter-rouge">DOCKER_HOST</code>. If you get “permission denied”, make sure your user can access the socket. If the buildpack itself fails,
run the build again with <code class="language-plaintext highlighter-rouge">-X</code> (Maven) or <code class="language-plaintext highlighter-rouge">--debug</code> (Gradle) to get detailed logs.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>That’s all it takes. Once the socket is on and <code class="language-plaintext highlighter-rouge">DOCKER_HOST</code> points at it, Spring Boot’s image building works the same
with Podman as it does with Docker. It’s a good option anywhere you don’t want to run a Docker daemon.</p>]]></content><author><name>Greg Baker</name></author><category term="spring-boot" /><category term="podman" /><category term="containers" /><category term="java" /><summary type="html"><![CDATA[Spring Boot can turn your app into a container image for you with the spring-boot:build-image Maven goal (or bootBuildImage in Gradle). Out of the box it expects Docker. If you’d rather use Podman, it works fine, but you need to set up three things first. This post walks through them.]]></summary></entry><entry><title type="html">Safe, validated JSON Patch processing in Spring</title><link href="https://stn-dts.github.io/2025/11/04/json-patch-processor.html" rel="alternate" type="text/html" title="Safe, validated JSON Patch processing in Spring" /><published>2025-11-04T00:00:00+00:00</published><updated>2025-11-04T00:00:00+00:00</updated><id>https://stn-dts.github.io/2025/11/04/json-patch-processor</id><content type="html" xml:base="https://stn-dts.github.io/2025/11/04/json-patch-processor.html"><![CDATA[<p>An HTTP <code class="language-plaintext highlighter-rouge">PATCH</code> request asks the server to change part of an object instead of replacing the whole thing. If your API
accepts them, you need a predictable way to apply those partial updates without ending up with an object that breaks
your rules (for example, a user with an empty email address).</p>

<p>Here’s the approach we use. We take the patch document, apply it to a <em>copy</em> of the object, validate the copy, and only
save it if it passes. The patch formats are the two standard ones: JSON Patch (RFC 6902), which is a list of operations
like “replace this field” or “remove that one”, and JSON Merge Patch (RFC 7396), which is a partial JSON object that
gets merged into the original. Because we use existing implementations of both, we don’t have to write our own patch
logic, and all the validation stays in one place.</p>

<p>The full code is at the end of the post. The sections before it explain how it works, what to watch out for, and how to
test it.</p>

<h2 id="what-the-processor-does">What the processor does</h2>

<p><code class="language-plaintext highlighter-rouge">JsonPatchProcessor</code> takes a Java domain object and a patch document (either a <code class="language-plaintext highlighter-rouge">JsonPatch</code> or a <code class="language-plaintext highlighter-rouge">JsonMergePatch</code>). It
then:</p>

<ol>
  <li>Serializes the object to JSON with Jackson’s <code class="language-plaintext highlighter-rouge">ObjectMapper</code>.</li>
  <li>Applies the patch to that JSON using Jakarta JSON (<code class="language-plaintext highlighter-rouge">jakarta.json</code>).</li>
  <li>Deserializes the patched JSON back into a new instance of the same class.</li>
  <li>Validates the new instance with Jakarta Bean Validation (<code class="language-plaintext highlighter-rouge">Validator</code>).</li>
</ol>

<p>If validation passes, you get the patched object back. If it fails, the processor throws a
<code class="language-plaintext highlighter-rouge">ConstraintViolationException</code>. If the JSON can’t be parsed or the patch can’t be applied, Jackson or Jakarta JSON
errors get wrapped in a <code class="language-plaintext highlighter-rouge">JsonPatchException</code>, so your controllers only have to deal with one exception type for those.</p>

<h2 id="why-we-like-this-design">Why we like this design</h2>

<p>Turning the object into JSON and back gives us a deep copy for free. The original object is never touched, so a bad
patch can’t leave it half-modified.</p>

<p>Validating the whole patched object, not just the fields that changed, means every rule on the class gets checked the
same way every time. Controllers don’t need their own copies of the validation logic.</p>

<p>The cost is that every patch goes through a full serialize and deserialize. That’s fine for typical payloads, but if
you expect very high throughput or very large objects, benchmark it first.</p>

<p>To sum up the error handling:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">JsonPatchException</code> means the JSON couldn’t be parsed or the patch couldn’t be applied. Map it to HTTP 400.</li>
  <li><code class="language-plaintext highlighter-rouge">ConstraintViolationException</code> means the patched object failed validation. Map it to HTTP 422 with field-level
messages.</li>
</ul>

<h2 id="usage">Usage</h2>

<p>In a Spring controller, inject the processor and pass it the existing object along with the parsed patch document. A
merge patch endpoint might look like this:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@PatchMapping</span><span class="o">(</span><span class="n">path</span> <span class="o">=</span> <span class="s">"/users/{id}"</span><span class="o">,</span> <span class="n">consumes</span> <span class="o">=</span> <span class="s">"application/merge-patch+json"</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">ResponseEntity</span><span class="o">&lt;</span><span class="nc">User</span><span class="o">&gt;</span> <span class="nf">patchUser</span><span class="o">(</span><span class="nd">@PathVariable</span> <span class="nc">Long</span> <span class="n">id</span><span class="o">,</span> <span class="nd">@RequestBody</span> <span class="nc">JsonMergePatch</span> <span class="n">mergePatch</span><span class="o">)</span> <span class="o">{</span>
	<span class="kd">final</span> <span class="kt">var</span> <span class="n">existing</span> <span class="o">=</span> <span class="n">userService</span><span class="o">.</span><span class="na">findById</span><span class="o">(</span><span class="n">id</span><span class="o">);</span>
	<span class="kd">final</span> <span class="kt">var</span> <span class="n">patched</span> <span class="o">=</span> <span class="n">jsonPatchProcessor</span><span class="o">.</span><span class="na">patch</span><span class="o">(</span><span class="n">existing</span><span class="o">,</span> <span class="n">mergePatch</span><span class="o">);</span>
	<span class="kd">final</span> <span class="kt">var</span> <span class="n">saved</span> <span class="o">=</span> <span class="n">userService</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="n">patched</span><span class="o">);</span>
	<span class="k">return</span> <span class="nc">ResponseEntity</span><span class="o">.</span><span class="na">ok</span><span class="o">(</span><span class="n">saved</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Here’s what the two kinds of payload look like. A JSON Merge Patch (RFC 7396):</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"email"</span><span class="p">:</span><span class="w"> </span><span class="s2">"new.email@example.com"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"profile"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"New Name"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>And a JSON Patch (RFC 6902):</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="w">
  </span><span class="p">{</span><span class="w"> </span><span class="nl">"op"</span><span class="p">:</span><span class="w"> </span><span class="s2">"replace"</span><span class="p">,</span><span class="w"> </span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/email"</span><span class="p">,</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"new.email@example.com"</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="p">{</span><span class="w"> </span><span class="nl">"op"</span><span class="p">:</span><span class="w"> </span><span class="s2">"remove"</span><span class="p">,</span><span class="w"> </span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/profile/oldField"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<h2 id="things-to-watch-out-for">Things to watch out for</h2>

<p>The processor deserializes back into <code class="language-plaintext highlighter-rouge">object.getClass()</code>. If your model uses polymorphic types (for example
<code class="language-plaintext highlighter-rouge">@JsonTypeInfo</code>, which tells Jackson which subclass to create) or abstract base classes, test that the subtype
information survives the round trip. If it doesn’t, you’ll need to keep the type metadata during serialization or
change the approach.</p>

<p>The example creates its own <code class="language-plaintext highlighter-rouge">ObjectMapper</code>. If your app already configures a shared one with date formats, modules, or
mixins, inject that one instead so patching behaves the same as the rest of the app.</p>

<p>Validation always runs on the whole object after the patch is applied. If you need to validate only the fields that
changed, you’ll need a different strategy.</p>

<p>Every patch does a serialize, a patch, and a deserialize. For large objects or high throughput, consider applying
patches to Jackson <code class="language-plaintext highlighter-rouge">JsonNode</code> trees and only mapping to domain objects when you need to.</p>

<p>For error responses, use a <code class="language-plaintext highlighter-rouge">@ControllerAdvice</code> to turn <code class="language-plaintext highlighter-rouge">ConstraintViolationException</code> into an HTTP 422 (unprocessable
content) with a structured list of field errors. Turn <code class="language-plaintext highlighter-rouge">JsonPatchException</code> into an HTTP 400 with a clear message about
the JSON or patch problem, without leaking sensitive details.</p>

<h2 id="testing">Testing</h2>

<p>These are the tests we’d write:</p>

<ol>
  <li>Happy path: a <code class="language-plaintext highlighter-rouge">JsonMergePatch</code> changes a field and the result passes validation.</li>
  <li>Validation failure: a patch produces an invalid object and <code class="language-plaintext highlighter-rouge">ConstraintViolationException</code> is thrown.</li>
  <li>Malformed patch: bad or incompatible operations produce a <code class="language-plaintext highlighter-rouge">JsonPatchException</code>.</li>
  <li>Polymorphism: if your app uses <code class="language-plaintext highlighter-rouge">@JsonTypeInfo</code> or similar, subtype fields survive the round trip.</li>
</ol>

<p>Here’s a JUnit test for the happy path:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Test</span>
<span class="kt">void</span> <span class="nf">mergePatch_updatesField_andValidates</span><span class="o">()</span> <span class="o">{</span>
	<span class="kd">final</span> <span class="kt">var</span> <span class="n">user</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">User</span><span class="o">(</span><span class="mi">1L</span><span class="o">,</span> <span class="s">"old@example.com"</span><span class="o">,</span> <span class="s">"Old Name"</span><span class="o">);</span>
	<span class="kd">final</span> <span class="kt">var</span> <span class="n">patch</span> <span class="o">=</span> <span class="nc">Json</span><span class="o">.</span><span class="na">createMergePatch</span><span class="o">(</span>
		<span class="nc">Json</span><span class="o">.</span><span class="na">createObjectBuilder</span><span class="o">()</span>
			<span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="s">"email"</span><span class="o">,</span> <span class="s">"new@example.com"</span><span class="o">)</span>
			<span class="o">.</span><span class="na">build</span><span class="o">());</span>
	<span class="kd">final</span> <span class="kt">var</span> <span class="n">patched</span> <span class="o">=</span> <span class="n">jsonPatchProcessor</span><span class="o">.</span><span class="na">patch</span><span class="o">(</span><span class="n">user</span><span class="o">,</span> <span class="n">patch</span><span class="o">);</span>
	<span class="n">assertEquals</span><span class="o">(</span><span class="s">"new@example.com"</span><span class="o">,</span> <span class="n">patched</span><span class="o">.</span><span class="na">getEmail</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="full-implementation">Full implementation</h2>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">java.io.StringReader</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.util.Set</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.util.function.Function</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">org.slf4j.Logger</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.slf4j.LoggerFactory</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.stereotype.Component</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.util.Assert</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">com.fasterxml.jackson.core.JsonProcessingException</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">com.fasterxml.jackson.databind.ObjectMapper</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">jakarta.json.Json</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">jakarta.json.JsonException</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">jakarta.json.JsonMergePatch</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">jakarta.json.JsonPatch</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">jakarta.json.JsonStructure</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">jakarta.json.JsonValue</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">jakarta.validation.ConstraintViolation</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">jakarta.validation.ConstraintViolationException</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">jakarta.validation.Validator</span><span class="o">;</span>

<span class="cm">/**
 * A component that applies JSON Patch and JSON Merge Patch operations to Java objects.
 * This class ensures that the patch operations are applied to a copy of the original
 * object and that the resulting object is validated before being returned.
 *
 * This component uses Jackson for serialization/deserialization and Jakarta JSON for patch operations.
 *
 * @see &lt;a href="https://tools.ietf.org/html/rfc6902"&gt;RFC 6902: JSON Patch&lt;/a&gt;
 * @see &lt;a href="https://tools.ietf.org/html/rfc7396"&gt;RFC 7396: JSON Merge Patch&lt;/a&gt;
 */</span>
<span class="nd">@Component</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">JsonPatchProcessor</span> <span class="o">{</span>

	<span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">Logger</span> <span class="n">log</span> <span class="o">=</span> <span class="nc">LoggerFactory</span><span class="o">.</span><span class="na">getLogger</span><span class="o">(</span><span class="nc">JsonPatchProcessor</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>

	<span class="kd">private</span> <span class="kd">final</span> <span class="nc">ObjectMapper</span> <span class="n">objectMapper</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ObjectMapper</span><span class="o">()</span>
		<span class="o">.</span><span class="na">findAndRegisterModules</span><span class="o">();</span>

	<span class="kd">private</span> <span class="kd">final</span> <span class="nc">Validator</span> <span class="n">validator</span><span class="o">;</span>

	<span class="kd">public</span> <span class="nf">JsonPatchProcessor</span><span class="o">(</span><span class="nc">Validator</span> <span class="n">validator</span><span class="o">)</span> <span class="o">{</span>
		<span class="k">this</span><span class="o">.</span><span class="na">validator</span> <span class="o">=</span> <span class="n">validator</span><span class="o">;</span>
	<span class="o">}</span>

	<span class="cm">/**
	 * Applies a JSON Merge Patch to the given object.
	 *
	 * @param &lt;T&gt; the type of the object to patch
	 * @param object the object to patch
	 * @param jsonMergePatch the JSON Merge Patch to apply
	 * @return the patched object
	 * @throws ConstraintViolationException if the patched object is not valid
	 */</span>
	<span class="kd">public</span> <span class="o">&lt;</span><span class="no">T</span><span class="o">&gt;</span> <span class="no">T</span> <span class="nf">patch</span><span class="o">(</span><span class="no">T</span> <span class="n">object</span><span class="o">,</span> <span class="nc">JsonMergePatch</span> <span class="n">jsonMergePatch</span><span class="o">)</span> <span class="o">{</span>
		<span class="k">return</span> <span class="nf">patch</span><span class="o">(</span><span class="n">object</span><span class="o">,</span> <span class="n">target</span> <span class="o">-&gt;</span> <span class="n">jsonMergePatch</span><span class="o">.</span><span class="na">apply</span><span class="o">(</span><span class="n">target</span><span class="o">));</span>
	<span class="o">}</span>

	<span class="cm">/**
	 * Applies a JSON Patch to the given object.
	 *
	 * @param &lt;T&gt; the type of the object to patch
	 * @param object the object to patch
	 * @param jsonPatch the JSON Patch to apply
	 * @return the patched object
	 * @throws ConstraintViolationException if the patched object is not valid
	 */</span>
	<span class="kd">public</span> <span class="o">&lt;</span><span class="no">T</span><span class="o">&gt;</span> <span class="no">T</span> <span class="nf">patch</span><span class="o">(</span><span class="no">T</span> <span class="n">object</span><span class="o">,</span> <span class="nc">JsonPatch</span> <span class="n">jsonPatch</span><span class="o">)</span> <span class="o">{</span>
		<span class="k">return</span> <span class="nf">patch</span><span class="o">(</span><span class="n">object</span><span class="o">,</span> <span class="n">target</span> <span class="o">-&gt;</span> <span class="n">jsonPatch</span><span class="o">.</span><span class="na">apply</span><span class="o">(</span><span class="n">target</span><span class="o">));</span>
	<span class="o">}</span>

	<span class="cm">/**
	 * Generic JSON patching method that delegates the actual patch application to a passed-in function.
	 * &lt;p&gt;
	 * This method first creates a deep copy of the input object. The patch function is then applied to this copy.
	 * Finally, the patched copy is validated. If validation is successful, the patched copy is returned.
	 *
	 * @param &lt;T&gt; the type of the object to patch
	 * @param object the object to patch
	 * @param patchFn a function that receives a {@link JsonStructure} representing the original object and returns the patched {@link JsonValue}
	 * @return the patched and validated object
	 * @throws ConstraintViolationException if the patched object does not pass validation
	 * @throws JsonPatchException if an error occurs during JSON processing
	 */</span>
	<span class="nd">@SuppressWarnings</span><span class="o">({</span> <span class="s">"unchecked"</span> <span class="o">})</span>
	<span class="kd">protected</span> <span class="o">&lt;</span><span class="no">T</span><span class="o">&gt;</span> <span class="no">T</span> <span class="nf">patch</span><span class="o">(</span><span class="no">T</span> <span class="n">object</span><span class="o">,</span> <span class="nc">Function</span><span class="o">&lt;?</span> <span class="kd">super</span> <span class="nc">JsonStructure</span><span class="o">,</span> <span class="o">?</span> <span class="kd">extends</span> <span class="nc">JsonValue</span><span class="o">&gt;</span> <span class="n">patchFn</span><span class="o">)</span> <span class="o">{</span>
		<span class="nc">Assert</span><span class="o">.</span><span class="na">notNull</span><span class="o">(</span><span class="n">object</span><span class="o">,</span> <span class="s">"object is required; it must not be null"</span><span class="o">);</span>
		<span class="nc">Assert</span><span class="o">.</span><span class="na">notNull</span><span class="o">(</span><span class="n">patchFn</span><span class="o">,</span> <span class="s">"patchFn is required; it must not be null"</span><span class="o">);</span>

		<span class="k">try</span> <span class="o">{</span>
			<span class="n">log</span><span class="o">.</span><span class="na">debug</span><span class="o">(</span><span class="s">"Patching object of type {}"</span><span class="o">,</span> <span class="n">object</span><span class="o">.</span><span class="na">getClass</span><span class="o">().</span><span class="na">getSimpleName</span><span class="o">());</span>

			<span class="kd">final</span> <span class="kt">var</span> <span class="n">originalJsonString</span> <span class="o">=</span> <span class="n">objectMapper</span><span class="o">.</span><span class="na">writeValueAsString</span><span class="o">(</span><span class="n">object</span><span class="o">);</span>

			<span class="k">try</span> <span class="o">(</span><span class="kd">final</span> <span class="kt">var</span> <span class="n">jsonReader</span> <span class="o">=</span> <span class="nc">Json</span><span class="o">.</span><span class="na">createReader</span><span class="o">(</span><span class="k">new</span> <span class="nc">StringReader</span><span class="o">(</span><span class="n">originalJsonString</span><span class="o">)))</span> <span class="o">{</span>
				<span class="kd">final</span> <span class="kt">var</span> <span class="n">originalJsonStructure</span> <span class="o">=</span> <span class="n">jsonReader</span><span class="o">.</span><span class="na">read</span><span class="o">();</span>
				<span class="kd">final</span> <span class="kt">var</span> <span class="n">patchedJsonValue</span> <span class="o">=</span> <span class="n">patchFn</span><span class="o">.</span><span class="na">apply</span><span class="o">(</span><span class="n">originalJsonStructure</span><span class="o">);</span>
				<span class="kd">final</span> <span class="kt">var</span> <span class="n">patchedJsonString</span> <span class="o">=</span> <span class="n">patchedJsonValue</span><span class="o">.</span><span class="na">toString</span><span class="o">();</span>
				<span class="kd">final</span> <span class="kt">var</span> <span class="n">patchedObject</span> <span class="o">=</span> <span class="n">objectMapper</span><span class="o">.</span><span class="na">readValue</span><span class="o">(</span><span class="n">patchedJsonString</span><span class="o">,</span> <span class="n">object</span><span class="o">.</span><span class="na">getClass</span><span class="o">());</span>

				<span class="n">log</span><span class="o">.</span><span class="na">debug</span><span class="o">(</span><span class="s">"Performing JSON patch validation"</span><span class="o">);</span>
				<span class="kd">final</span> <span class="nc">Set</span><span class="o">&lt;</span><span class="nc">ConstraintViolation</span><span class="o">&lt;</span><span class="nc">Object</span><span class="o">&gt;&gt;</span> <span class="n">violations</span> <span class="o">=</span> <span class="n">validator</span><span class="o">.</span><span class="na">validate</span><span class="o">(</span><span class="n">patchedObject</span><span class="o">);</span>
				<span class="k">if</span> <span class="o">(</span><span class="n">violations</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">()</span> <span class="o">==</span> <span class="kc">false</span><span class="o">)</span> <span class="o">{</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">ConstraintViolationException</span><span class="o">(</span><span class="n">violations</span><span class="o">);</span> <span class="o">}</span>
				<span class="n">log</span><span class="o">.</span><span class="na">debug</span><span class="o">(</span><span class="s">"No validation errors for {}"</span><span class="o">,</span> <span class="n">object</span><span class="o">.</span><span class="na">getClass</span><span class="o">().</span><span class="na">getSimpleName</span><span class="o">());</span>

				<span class="k">return</span> <span class="o">(</span><span class="no">T</span><span class="o">)</span> <span class="n">patchedObject</span><span class="o">;</span>
			<span class="o">}</span>
		<span class="o">}</span>
		<span class="c1">// JsonException can be thrown by Johnzon</span>
		<span class="c1">// JsonProcessingException can be thrown by Jackson</span>
		<span class="k">catch</span> <span class="o">(</span><span class="kd">final</span> <span class="nc">JsonException</span> <span class="o">|</span> <span class="nc">JsonProcessingException</span> <span class="n">exception</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">JsonPatchException</span><span class="o">(</span><span class="s">"An error occurred while JSON-Patching"</span><span class="o">,</span> <span class="n">exception</span><span class="o">);</span>
		<span class="o">}</span>
	<span class="o">}</span>

<span class="o">}</span>
</code></pre></div></div>

<h2 id="spring-converters">Spring converters</h2>

<p>Spring MVC doesn’t know how to turn a request body into a <code class="language-plaintext highlighter-rouge">JsonMergePatch</code> or <code class="language-plaintext highlighter-rouge">JsonPatch</code> on its own. These two message
converters handle that.</p>

<h3 id="jsonmergepatchhttpmessageconverter">JsonMergePatchHttpMessageConverter</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">org.springframework.http.HttpInputMessage</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.http.HttpOutputMessage</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.http.converter.AbstractHttpMessageConverter</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.http.converter.HttpMessageNotReadableException</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.http.converter.HttpMessageNotWritableException</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.stereotype.Component</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">jakarta.json.Json</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">jakarta.json.JsonMergePatch</span><span class="o">;</span>

<span class="cm">/**
 * An {@link AbstractHttpMessageConverter} that can read and write {@link JsonMergePatch} objects.
 *
 * @see &lt;a href="https://tools.ietf.org/html/rfc7396"&gt;RFC 7396: JSON Merge Patch&lt;/a&gt;
 */</span>
<span class="nd">@Component</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">JsonMergePatchHttpMessageConverter</span> <span class="kd">extends</span> <span class="nc">AbstractHttpMessageConverter</span><span class="o">&lt;</span><span class="nc">JsonMergePatch</span><span class="o">&gt;</span> <span class="o">{</span>

	<span class="kd">public</span> <span class="nf">JsonMergePatchHttpMessageConverter</span><span class="o">()</span> <span class="o">{</span>
		<span class="kd">super</span><span class="o">(</span><span class="nc">JsonPatchMediaTypes</span><span class="o">.</span><span class="na">JSON_MERGE_PATCH</span><span class="o">);</span>
	<span class="o">}</span>

	<span class="nd">@Override</span>
	<span class="kd">protected</span> <span class="kt">boolean</span> <span class="nf">supports</span><span class="o">(</span><span class="nc">Class</span><span class="o">&lt;?&gt;</span> <span class="n">clazz</span><span class="o">)</span> <span class="o">{</span>
		<span class="k">return</span> <span class="nc">JsonMergePatch</span><span class="o">.</span><span class="na">class</span><span class="o">.</span><span class="na">isAssignableFrom</span><span class="o">(</span><span class="n">clazz</span><span class="o">);</span>
	<span class="o">}</span>

	<span class="nd">@Override</span>
	<span class="kd">protected</span> <span class="nc">JsonMergePatch</span> <span class="nf">readInternal</span><span class="o">(</span><span class="nc">Class</span><span class="o">&lt;?</span> <span class="kd">extends</span> <span class="nc">JsonMergePatch</span><span class="o">&gt;</span> <span class="n">clazz</span><span class="o">,</span> <span class="nc">HttpInputMessage</span> <span class="n">httpInputMessage</span><span class="o">)</span> <span class="o">{</span>
		<span class="k">try</span> <span class="o">(</span><span class="kd">final</span> <span class="kt">var</span> <span class="n">jsonReader</span> <span class="o">=</span> <span class="nc">Json</span><span class="o">.</span><span class="na">createReader</span><span class="o">(</span><span class="n">httpInputMessage</span><span class="o">.</span><span class="na">getBody</span><span class="o">()))</span> <span class="o">{</span>
			<span class="k">return</span> <span class="nc">Json</span><span class="o">.</span><span class="na">createMergePatch</span><span class="o">(</span><span class="n">jsonReader</span><span class="o">.</span><span class="na">readValue</span><span class="o">());</span>
		<span class="o">}</span>
		<span class="k">catch</span> <span class="o">(</span><span class="kd">final</span> <span class="nc">Exception</span> <span class="n">exception</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">HttpMessageNotReadableException</span><span class="o">(</span><span class="s">"Could not read JSON merge-patch: "</span> <span class="o">+</span> <span class="n">exception</span><span class="o">.</span><span class="na">getMessage</span><span class="o">(),</span> <span class="n">exception</span><span class="o">,</span> <span class="n">httpInputMessage</span><span class="o">);</span>
		<span class="o">}</span>
	<span class="o">}</span>

	<span class="nd">@Override</span>
	<span class="kd">protected</span> <span class="kt">void</span> <span class="nf">writeInternal</span><span class="o">(</span><span class="nc">JsonMergePatch</span> <span class="n">jsonMergePatch</span><span class="o">,</span> <span class="nc">HttpOutputMessage</span> <span class="n">httpOutputMessage</span><span class="o">)</span> <span class="o">{</span>
		<span class="k">try</span> <span class="o">(</span><span class="kd">final</span> <span class="kt">var</span> <span class="n">jsonWriter</span> <span class="o">=</span> <span class="nc">Json</span><span class="o">.</span><span class="na">createWriter</span><span class="o">(</span><span class="n">httpOutputMessage</span><span class="o">.</span><span class="na">getBody</span><span class="o">()))</span> <span class="o">{</span>
			<span class="n">jsonWriter</span><span class="o">.</span><span class="na">write</span><span class="o">(</span><span class="n">jsonMergePatch</span><span class="o">.</span><span class="na">toJsonValue</span><span class="o">());</span>
		<span class="o">}</span>
		<span class="k">catch</span> <span class="o">(</span><span class="kd">final</span> <span class="nc">Exception</span> <span class="n">exception</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">HttpMessageNotWritableException</span><span class="o">(</span><span class="s">"Could not write JSON merge-patch: "</span> <span class="o">+</span> <span class="n">exception</span><span class="o">.</span><span class="na">getMessage</span><span class="o">(),</span> <span class="n">exception</span><span class="o">);</span>
		<span class="o">}</span>
	<span class="o">}</span>

<span class="o">}</span>
</code></pre></div></div>

<h3 id="jsonpatchhttpmessageconverter">JsonPatchHttpMessageConverter</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">package</span> <span class="nn">ca.gov.dtsstn.vacman.api.json</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">org.springframework.http.HttpInputMessage</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.http.HttpOutputMessage</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.http.converter.AbstractHttpMessageConverter</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.http.converter.HttpMessageNotReadableException</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.http.converter.HttpMessageNotWritableException</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.springframework.stereotype.Component</span><span class="o">;</span>

<span class="kn">import</span> <span class="nn">jakarta.json.Json</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">jakarta.json.JsonPatch</span><span class="o">;</span>

<span class="cm">/**
 * An {@link AbstractHttpMessageConverter} that can read and write {@link JsonPatch} objects.
 *
 * @see &lt;a href="https://tools.ietf.org/html/rfc6902"&gt;RFC 6902: JavaScript Object Notation (JSON) Patch&lt;/a&gt;
 */</span>
<span class="nd">@Component</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">JsonPatchHttpMessageConverter</span> <span class="kd">extends</span> <span class="nc">AbstractHttpMessageConverter</span><span class="o">&lt;</span><span class="nc">JsonPatch</span><span class="o">&gt;</span> <span class="o">{</span>

	<span class="kd">public</span> <span class="nf">JsonPatchHttpMessageConverter</span><span class="o">()</span> <span class="o">{</span>
		<span class="kd">super</span><span class="o">(</span><span class="nc">JsonPatchMediaTypes</span><span class="o">.</span><span class="na">JSON_PATCH</span><span class="o">);</span>
	<span class="o">}</span>

	<span class="nd">@Override</span>
	<span class="kd">protected</span> <span class="kt">boolean</span> <span class="nf">supports</span><span class="o">(</span><span class="nc">Class</span><span class="o">&lt;?&gt;</span> <span class="n">clazz</span><span class="o">)</span> <span class="o">{</span>
		<span class="k">return</span> <span class="nc">JsonPatch</span><span class="o">.</span><span class="na">class</span><span class="o">.</span><span class="na">isAssignableFrom</span><span class="o">(</span><span class="n">clazz</span><span class="o">);</span>
	<span class="o">}</span>

	<span class="nd">@Override</span>
	<span class="kd">protected</span> <span class="nc">JsonPatch</span> <span class="nf">readInternal</span><span class="o">(</span><span class="nc">Class</span><span class="o">&lt;?</span> <span class="kd">extends</span> <span class="nc">JsonPatch</span><span class="o">&gt;</span> <span class="n">clazz</span><span class="o">,</span> <span class="nc">HttpInputMessage</span> <span class="n">httpInputMessage</span><span class="o">)</span> <span class="o">{</span>
		<span class="k">try</span> <span class="o">(</span><span class="kd">final</span> <span class="kt">var</span> <span class="n">jsonReader</span> <span class="o">=</span> <span class="nc">Json</span><span class="o">.</span><span class="na">createReader</span><span class="o">(</span><span class="n">httpInputMessage</span><span class="o">.</span><span class="na">getBody</span><span class="o">()))</span> <span class="o">{</span>
			<span class="k">return</span> <span class="nc">Json</span><span class="o">.</span><span class="na">createPatch</span><span class="o">(</span><span class="n">jsonReader</span><span class="o">.</span><span class="na">readArray</span><span class="o">());</span>
		<span class="o">}</span>
		<span class="k">catch</span> <span class="o">(</span><span class="kd">final</span> <span class="nc">Exception</span> <span class="n">exception</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">HttpMessageNotReadableException</span><span class="o">(</span><span class="s">"Could not read JSON patch: "</span> <span class="o">+</span> <span class="n">exception</span><span class="o">.</span><span class="na">getMessage</span><span class="o">(),</span> <span class="n">exception</span><span class="o">,</span> <span class="n">httpInputMessage</span><span class="o">);</span>
		<span class="o">}</span>
	<span class="o">}</span>

	<span class="nd">@Override</span>
	<span class="kd">protected</span> <span class="kt">void</span> <span class="nf">writeInternal</span><span class="o">(</span><span class="nc">JsonPatch</span> <span class="n">jsonPatch</span><span class="o">,</span> <span class="nc">HttpOutputMessage</span> <span class="n">httpOutputMessage</span><span class="o">)</span> <span class="o">{</span>
		<span class="k">try</span> <span class="o">(</span><span class="kd">final</span> <span class="kt">var</span> <span class="n">jsonWriter</span> <span class="o">=</span> <span class="nc">Json</span><span class="o">.</span><span class="na">createWriter</span><span class="o">(</span><span class="n">httpOutputMessage</span><span class="o">.</span><span class="na">getBody</span><span class="o">()))</span> <span class="o">{</span>
			<span class="n">jsonWriter</span><span class="o">.</span><span class="na">write</span><span class="o">(</span><span class="n">jsonPatch</span><span class="o">.</span><span class="na">toJsonArray</span><span class="o">());</span>
		<span class="o">}</span>
		<span class="k">catch</span> <span class="o">(</span><span class="kd">final</span> <span class="nc">Exception</span> <span class="n">exception</span><span class="o">)</span> <span class="o">{</span>
			<span class="k">throw</span> <span class="k">new</span> <span class="nf">HttpMessageNotWritableException</span><span class="o">(</span><span class="s">"Could not write JSON patch: "</span> <span class="o">+</span> <span class="n">exception</span><span class="o">.</span><span class="na">getMessage</span><span class="o">(),</span> <span class="n">exception</span><span class="o">);</span>
		<span class="o">}</span>
	<span class="o">}</span>

<span class="o">}</span>
</code></pre></div></div>]]></content><author><name>Greg Baker</name></author><category term="api" /><category term="rest" /><category term="json-patch" /><summary type="html"><![CDATA[An HTTP PATCH request asks the server to change part of an object instead of replacing the whole thing. If your API accepts them, you need a predictable way to apply those partial updates without ending up with an object that breaks your rules (for example, a user with an empty email address).]]></summary></entry><entry><title type="html">Selective CI for monorepos – detecting changes and running only the jobs you need</title><link href="https://stn-dts.github.io/2025/10/21/monorepo-github-actions.html" rel="alternate" type="text/html" title="Selective CI for monorepos – detecting changes and running only the jobs you need" /><published>2025-10-21T00:00:00+00:00</published><updated>2025-10-21T00:00:00+00:00</updated><id>https://stn-dts.github.io/2025/10/21/monorepo-github-actions</id><content type="html" xml:base="https://stn-dts.github.io/2025/10/21/monorepo-github-actions.html"><![CDATA[<p>In a monorepo, every pull request kicks off every CI job by default. Change one line in the frontend and you still wait
for the backend to compile and run its tests. In this post I’ll show how we set up a GitHub Actions workflow that checks
which top-level folders changed and only runs the jobs for those folders. I’ll also cover when it’s worth doing, a few
gotchas, and some other ways to solve the same problem.</p>

<h2 id="how-the-workflow-is-set-up">How the workflow is set up</h2>

<p>The workflow starts with a job called <code class="language-plaintext highlighter-rouge">detect-changes</code>. It checks whether any files changed under <code class="language-plaintext highlighter-rouge">backend/</code>,
<code class="language-plaintext highlighter-rouge">frontend/</code>, and <code class="language-plaintext highlighter-rouge">gitops/</code>, and publishes the answer as three outputs: <code class="language-plaintext highlighter-rouge">backend-changed</code>, <code class="language-plaintext highlighter-rouge">frontend-changed</code>, and
<code class="language-plaintext highlighter-rouge">gitops-changed</code>. (Job outputs are values one job can hand to the jobs that run after it.)</p>

<p>The actual change detection is done by the <code class="language-plaintext highlighter-rouge">dorny/paths-filter@v3</code> action. Every other job looks at one of those
outputs in an <code class="language-plaintext highlighter-rouge">if</code> condition, like <code class="language-plaintext highlighter-rouge">if: ${{ needs.detect-changes.outputs.backend-changed == 'true' }}</code>, and gets
skipped if its folder didn’t change.</p>

<p>The full sample workflow is at the end of this post. It runs on <code class="language-plaintext highlighter-rouge">push</code> and <code class="language-plaintext highlighter-rouge">pull_request</code> for <code class="language-plaintext highlighter-rouge">main</code> and <code class="language-plaintext highlighter-rouge">release/**</code>
branches, and you can also trigger it by hand. Change the event filters to fit your own branching and release setup.</p>

<h2 id="step-by-step">Step by step</h2>

<ol>
  <li>
    <p>Check out the repo. The <code class="language-plaintext highlighter-rouge">detect-changes</code> job runs <code class="language-plaintext highlighter-rouge">actions/checkout@v4</code> to fetch the commits for the event. The
paths-filter action needs the repository contents to work out which files changed.</p>
  </li>
  <li>
    <p>Run <code class="language-plaintext highlighter-rouge">paths-filter</code>. The job calls <code class="language-plaintext highlighter-rouge">dorny/paths-filter@v3</code> with one named filter per folder, each using a glob
pattern like <code class="language-plaintext highlighter-rouge">backend/**</code>. The action compares the current commits against the base branch (for pull requests) or
the previous commit (for pushes) and checks whether any matching files were added, modified, or deleted.</p>
  </li>
  <li>
    <p>Expose the outputs. The action sets an output for each named filter, for example <code class="language-plaintext highlighter-rouge">steps.paths.outputs.backend</code>.
It’s <code class="language-plaintext highlighter-rouge">true</code> if something matched and <code class="language-plaintext highlighter-rouge">false</code> if not. The <code class="language-plaintext highlighter-rouge">detect-changes</code> job passes these on as its own outputs, so
any job that declares <code class="language-plaintext highlighter-rouge">needs: detect-changes</code> can read them.</p>
  </li>
  <li>
    <p>Skip what didn’t change. Jobs like <code class="language-plaintext highlighter-rouge">test-backend</code>, <code class="language-plaintext highlighter-rouge">build-backend</code>, <code class="language-plaintext highlighter-rouge">test-frontend</code>, and <code class="language-plaintext highlighter-rouge">build-frontend</code> declare
<code class="language-plaintext highlighter-rouge">needs: detect-changes</code> and use an <code class="language-plaintext highlighter-rouge">if</code> condition that checks the matching output. If it’s <code class="language-plaintext highlighter-rouge">true</code>, the job runs.
Otherwise it’s skipped.</p>
  </li>
</ol>

<h2 id="why-bother">Why bother</h2>

<p>The main reason is cost. Compiling, testing, and building are the expensive parts of CI, and there’s no point paying for
them when the code they cover hasn’t changed. It also means faster feedback: a pull request that only touches docs or
one small part of the repo doesn’t have to wait on unrelated jobs.</p>

<p>It keeps things organized too. Each top-level folder can have its own CI jobs and build matrix, and they all share one
place for change detection instead of each repeating it. This is a common pattern in monorepos that hold several
services, SDKs, or components.</p>

<h2 id="when-to-use-it">When to use it</h2>

<p>It’s a good fit when your repo has several independent services or packages in separate folders, when builds or tests
are slow enough that skipping them matters, or when you mix technologies (say a Java backend, a TypeScript frontend, and
infrastructure manifests) and a change to one shouldn’t trigger CI for the others.</p>

<p>If your codebase is small and CI is already fast, the extra complexity probably isn’t worth it.</p>

<h2 id="branch-protection-and-required-checks">Branch protection and required checks</h2>

<p>This pattern plays nicely with branch protection rules. You can mark the conditional jobs as required status checks,
and they’ll only be enforced when they actually run.</p>

<p>For example, say you require <code class="language-plaintext highlighter-rouge">ci/test-backend</code> and <code class="language-plaintext highlighter-rouge">ci/build-backend</code> to pass. If a pull request only changes frontend
code, those backend jobs are skipped, and GitHub doesn’t treat them as failing or blocking the merge. They show up as
“skipped” in the pull request checks, not as failures. So you only run the checks you need, and the quality gates still
apply to the parts that changed.</p>

<p>To set up required checks, go to your repository’s <strong>Settings &gt; Branches</strong>, click <strong>Add rule</strong> (or edit the existing
rule for your protected branch, e.g. <code class="language-plaintext highlighter-rouge">main</code>), and under <strong>Require status checks to pass before merging</strong>, add the names
of the jobs you want to require. These need to match the job names in your workflow, such as <code class="language-plaintext highlighter-rouge">ci/test-backend</code>.</p>

<h2 id="things-to-get-right">Things to get right</h2>

<p><strong>Fetch the full history.</strong> Change-detection actions like <code class="language-plaintext highlighter-rouge">dorny/paths-filter</code> may need the full Git history to compute
diffs correctly. Set <code class="language-plaintext highlighter-rouge">fetch-depth: 0</code> on <code class="language-plaintext highlighter-rouge">actions/checkout</code> to get it. The action’s documentation has the details. If
you use a shallow clone that doesn’t include the previous commit, change detection can miss changes entirely. The
sample workflow already sets <code class="language-plaintext highlighter-rouge">fetch-depth: 0</code> in <code class="language-plaintext highlighter-rouge">detect-changes</code>.</p>

<p><strong>Pull requests vs. pushes.</strong> For pull requests, the action compares the PR branch to the base branch. For pushes, it
compares the new commit to the previous one. <code class="language-plaintext highlighter-rouge">dorny/paths-filter</code> handles both cases for you, but keep in mind that
merge commits and squash merges can affect what shows up in the diff.</p>

<p><strong>Be precise with globs.</strong> <code class="language-plaintext highlighter-rouge">backend/**</code> matches everything under <code class="language-plaintext highlighter-rouge">backend/</code>. If you only care about changes under
<code class="language-plaintext highlighter-rouge">backend/src</code>, use <code class="language-plaintext highlighter-rouge">backend/src/**</code> instead.</p>

<p><strong>One invocation or several.</strong> You can call <code class="language-plaintext highlighter-rouge">dorny/paths-filter</code> once per folder, each with a single filter, but the
action also lets you define several named filters in one call. That’s what the sample does. It means fewer steps, it’s
a little more efficient, and the outputs are cleaner.</p>

<p><strong>Outputs are strings.</strong> The outputs from <code class="language-plaintext highlighter-rouge">dorny/paths-filter</code> are the strings <code class="language-plaintext highlighter-rouge">"true"</code> and <code class="language-plaintext highlighter-rouge">"false"</code>, not booleans.
Your <code class="language-plaintext highlighter-rouge">if</code> conditions need to compare them as strings, e.g. <code class="language-plaintext highlighter-rouge">if: ${{ needs.detect-changes.outputs.backend-changed == 'true' }}</code>.</p>

<p><strong>Don’t forget <code class="language-plaintext highlighter-rouge">needs</code>.</strong> A job has to declare <code class="language-plaintext highlighter-rouge">needs: detect-changes</code> to read its outputs.</p>

<h2 id="edge-cases">Edge cases</h2>

<p>If a pull request touches several folders, several outputs will be <code class="language-plaintext highlighter-rouge">true</code> and several sets of jobs will run. That’s
what you want, but it does mean one PR can still trigger a lot of jobs.</p>

<p>Sometimes the changes that matter aren’t in the folders you’re watching. If your CI depends on shared build scripts or
on the GitHub Actions workflow files themselves, add those paths to your filters. Another option is a separate
<code class="language-plaintext highlighter-rouge">shared-changes</code> filter matching things like <code class="language-plaintext highlighter-rouge">.github/workflows/**</code> or <code class="language-plaintext highlighter-rouge">docker/*</code> that makes every job run when shared
infrastructure changes.</p>

<p>Finally, <code class="language-plaintext highlighter-rouge">dorny/paths-filter</code> is well maintained, but it’s still a third-party action. If you’d rather not depend on it,
you can write the change detection yourself with <code class="language-plaintext highlighter-rouge">git diff</code> in a small script, or switch to a different action.</p>

<h2 id="other-ways-to-do-it">Other ways to do it</h2>

<p>GitHub Actions has built-in <code class="language-plaintext highlighter-rouge">paths</code> and <code class="language-plaintext highlighter-rouge">paths-ignore</code> filters for <code class="language-plaintext highlighter-rouge">push</code> and <code class="language-plaintext highlighter-rouge">pull_request</code> events, but they work at
the workflow level. They decide whether the whole workflow runs, so you can’t use them to turn individual jobs on and
off from one shared detection step. They also can’t express cross-folder logic as flexibly as a dedicated detection job.</p>

<p>You can also skip third-party actions entirely and add a shell step that runs <code class="language-plaintext highlighter-rouge">git diff --name-only $BASE...$HEAD</code>,
then sets outputs by writing to the <code class="language-plaintext highlighter-rouge">GITHUB_OUTPUT</code> file (e.g. <code class="language-plaintext highlighter-rouge">echo "changed=true" &gt;&gt; $GITHUB_OUTPUT</code>). That gives you
full control over the logic.</p>

<p>For larger JavaScript monorepos, tools like Nx or Lerna can work out which projects are affected by a change and give
you a list of them. They go well beyond what a simple folder check can do.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>A <code class="language-plaintext highlighter-rouge">detect-changes</code> job using <code class="language-plaintext highlighter-rouge">dorny/paths-filter</code>, plus <code class="language-plaintext highlighter-rouge">if</code> conditions on the other jobs, is a simple and easy to
maintain way to run CI only for the parts of a monorepo that changed. It cuts CI costs and gets feedback to developers
faster. For bigger or more specialized monorepos, you can pair it with monorepo tooling or a custom <code class="language-plaintext highlighter-rouge">git diff</code> script.</p>

<p>Here’s a complete example workflow. You can copy it into <code class="language-plaintext highlighter-rouge">.github/workflows/</code> and adapt it to your repo.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="na">name</span><span class="pi">:</span> <span class="s">Build and test solution</span>
<span class="na">permissions</span><span class="pi">:</span> <span class="s">read-all</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">main</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">release/**'</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">main</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">release/**'</span>
  <span class="na">workflow_call</span><span class="pi">:</span> <span class="pi">{}</span>
  <span class="na">workflow_dispatch</span><span class="pi">:</span> <span class="pi">{}</span>

<span class="na">env</span><span class="pi">:</span>
  <span class="na">CI</span><span class="pi">:</span> <span class="no">true</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">detect-changes</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">ci/detect-changes</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">fetch-depth</span><span class="pi">:</span> <span class="m">0</span>
      <span class="pi">-</span> <span class="na">id</span><span class="pi">:</span> <span class="s">paths</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">dorny/paths-filter@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">filters</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">backend:</span>
              <span class="s">- backend/**</span>
            <span class="s">frontend:</span>
              <span class="s">- frontend/**</span>
            <span class="s">gitops:</span>
              <span class="s">- gitops/**</span>
    <span class="na">outputs</span><span class="pi">:</span>
      <span class="na">backend-changed</span><span class="pi">:</span> <span class="s">${{ steps.paths.outputs.backend }}</span>
      <span class="na">frontend-changed</span><span class="pi">:</span> <span class="s">${{ steps.paths.outputs.frontend }}</span>
      <span class="na">gitops-changed</span><span class="pi">:</span> <span class="s">${{ steps.paths.outputs.gitops }}</span>

  <span class="na">test-backend</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">ci/test-backend</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">detect-changes</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">${{ needs.detect-changes.outputs.backend-changed == 'true' }}</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-java@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">java-version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">21'</span>
          <span class="na">distribution</span><span class="pi">:</span> <span class="s1">'</span><span class="s">temurin'</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">mvn clean verify</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">backend/</span>

  <span class="na">build-backend</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">ci/build-backend</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">detect-changes</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">${{ needs.detect-changes.outputs.backend-changed == 'true' }}</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-java@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">java-version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">21'</span>
          <span class="na">distribution</span><span class="pi">:</span> <span class="s1">'</span><span class="s">temurin'</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">mvn spring-boot:build-image</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">backend/</span>

  <span class="na">test-frontend</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">ci/test-frontend</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">detect-changes</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">${{ needs.detect-changes.outputs.frontend-changed == 'true' }}</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-node@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">node-version</span><span class="pi">:</span> <span class="s">22.x</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">pnpm/action-setup@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">version</span><span class="pi">:</span> <span class="m">10</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">pnpm install --frozen-lockfile</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">frontend/</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">pnpm run typecheck</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">frontend/</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">pnpm run format:check</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">frontend/</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">pnpm run lint:check</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">frontend/</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">pnpm run test -- --coverage</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">frontend/</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">npx playwright install chromium --with-deps</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">frontend/</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">pnpm run test:e2e</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">frontend/</span>

  <span class="na">build-frontend</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">ci/build-frontend</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">detect-changes</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">${{ needs.detect-changes.outputs.frontend-changed == 'true' }}</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-node@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">node-version</span><span class="pi">:</span> <span class="s">22.x</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">pnpm/action-setup@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">version</span><span class="pi">:</span> <span class="m">10</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">pnpm install</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">frontend/</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">podman build --file containerfile .</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">frontend/</span>

</code></pre></div></div>]]></content><author><name>Greg Baker</name></author><category term="ci/cd" /><category term="github-actions" /><category term="monorepo" /><category term="devops" /><summary type="html"><![CDATA[In a monorepo, every pull request kicks off every CI job by default. Change one line in the frontend and you still wait for the backend to compile and run its tests. In this post I’ll show how we set up a GitHub Actions workflow that checks which top-level folders changed and only runs the jobs for those folders. I’ll also cover when it’s worth doing, a few gotchas, and some other ways to solve the same problem.]]></summary></entry><entry><title type="html">Semver for web apps is pointless</title><link href="https://stn-dts.github.io/2025/10/20/the-case-against-semver.html" rel="alternate" type="text/html" title="Semver for web apps is pointless" /><published>2025-10-20T00:00:00+00:00</published><updated>2025-10-20T00:00:00+00:00</updated><id>https://stn-dts.github.io/2025/10/20/the-case-against-semver</id><content type="html" xml:base="https://stn-dts.github.io/2025/10/20/the-case-against-semver.html"><![CDATA[<p>This might sound like heresy in some dev circles, but here goes: using semantic versioning for a web application is a
waste of time.</p>

<p>For years we’ve followed <code class="language-plaintext highlighter-rouge">MAJOR.MINOR.PATCH</code> as if it were scripture. And semver is a great system for what it was
built for. It’s a contract between a library or API author and the developers who use it. The version number tells you
whether an update will break your code (<code class="language-plaintext highlighter-rouge">MAJOR</code>), add a feature without breaking anything (<code class="language-plaintext highlighter-rouge">MINOR</code>), or just fix a bug
(<code class="language-plaintext highlighter-rouge">PATCH</code>).</p>

<p>Your end users aren’t developers calling your API, though. They’re people using your app. And with a continuously
deployed web app, they’re <em>always</em> on the latest version.</p>

<h2 id="what-counts-as-a-breaking-change-in-a-ui">What counts as a breaking change in a UI?</h2>

<p>Semver is built around the idea of a breaking change. For an API that’s easy to define. Rename an endpoint or remove a
required field and you’ve broken the contract, and client code fails.</p>

<p>Try applying that to a user interface. Say you move a button from the top right to the top left. Is that a breaking
change? For a user with muscle memory for where that button was, it definitely is. Say you tweak a shade of blue to
improve accessibility. For a color-blind user, that could be a big change. Meanwhile, a complete backend rewrite that
you’d call a <code class="language-plaintext highlighter-rouge">MAJOR</code> change might go completely unnoticed.</p>

<p>For someone using a web app, <em>any</em> UI change can be a breaking change. Semver’s fixed categories don’t map onto
something as fuzzy and personal as how a user experiences an interface.</p>

<h2 id="your-users-dont-care">Your users don’t care</h2>

<p>Semver is a message for developers. Package managers and engineers read it to judge how risky an upgrade is. Even if you
show version numbers to your users, they have no idea what <code class="language-plaintext highlighter-rouge">2.5.1</code> versus <code class="language-plaintext highlighter-rouge">3.0.0</code> means.</p>

<p>Users also don’t get to pick which version of a web app they run. They get whatever is deployed. So the whole message
semver sends is aimed at an audience that, for web apps, doesn’t exist.</p>

<h2 id="what-to-use-instead">What to use instead</h2>

<p>Dropping semver doesn’t mean dropping version numbers. It means picking a scheme that fits the job. Here are a few that
work much better for web apps.</p>

<p><strong>Calendar versioning (CalVer).</strong> A version like <code class="language-plaintext highlighter-rouge">YYYY.MM.DD</code> or <code class="language-plaintext highlighter-rouge">YYYY.WW</code> (year and week number) means something to
everyone. Support, product managers, and even curious users can tell at a glance when a release went out. It’s simple,
and it ties each version to a point in time.</p>

<p><strong>Build numbers or commit hashes.</strong> If you deploy continuously, the most honest version is the git commit hash. It points
to the exact code running in production. For internal tracking, a build number from your CI/CD pipeline is often all you
need.</p>

<p><strong>A plain counter.</strong> Just use a number that goes up by one with every release (<code class="language-plaintext highlighter-rouge">v45</code>, <code class="language-plaintext highlighter-rouge">v46</code>). It shows progress, it’s
easy to understand, and it works well for internal QA and support.</p>

<p>Version numbers exist to communicate. For libraries and APIs, semver does that well: it signals breaking changes, new
features, and fixes. For user-facing, continuously deployed web apps, it turns into a ritual that sparks pointless
internal debates and gives users nothing. Pick a scheme that matches what’s really happening, like a date, a build or
commit ID, or a simple release number, so your versions help your support and product teams instead of slowing them
down.</p>]]></content><author><name>Greg Baker</name></author><category term="semver" /><category term="semantic-versioning" /><summary type="html"><![CDATA[This might sound like heresy in some dev circles, but here goes: using semantic versioning for a web application is a waste of time.]]></summary></entry><entry><title type="html">Creating an MSGraph client using Effect-TS</title><link href="https://stn-dts.github.io/2025/09/26/effectful-msgraph-client.html" rel="alternate" type="text/html" title="Creating an MSGraph client using Effect-TS" /><published>2025-09-26T00:00:00+00:00</published><updated>2025-09-26T00:00:00+00:00</updated><id>https://stn-dts.github.io/2025/09/26/effectful-msgraph-client</id><content type="html" xml:base="https://stn-dts.github.io/2025/09/26/effectful-msgraph-client.html"><![CDATA[<p>Calling an external API sounds simple until you have to deal with network failures, responses that don’t look like you
expected, and a pile of async code. The Microsoft Graph API (MSGraph) is no different. It has its own rules too, like
limits on how many operations you can batch together and a specific way to authenticate.</p>

<p>We used Effect-TS to manage this. Effect is a TypeScript library that replaces promises and <code class="language-plaintext highlighter-rouge">async/await</code> with its own
type called an <code class="language-plaintext highlighter-rouge">Effect</code>. An <code class="language-plaintext highlighter-rouge">Effect</code> is a description of some work, like an API call, that hasn’t run yet. Because it’s
just a value, you can combine it with other effects, test it, and see in its type exactly what it returns and how it can
fail.</p>

<p>In this post I’ll walk through a small MSGraph client built with Effect. It covers authentication, checking the shape of
API responses, and batching, which is the trickiest part.</p>

<h2 id="schemas-and-errors">Schemas and errors</h2>

<p>Before making any API calls, we define what data we expect back and what our errors look like.</p>

<h3 id="defining-data-with-effectschema">Defining data with <code class="language-plaintext highlighter-rouge">effect/Schema</code></h3>

<p>Effect’s <code class="language-plaintext highlighter-rouge">Schema</code> module lets us describe the shape of our data and then parse and validate against it. If an API
response doesn’t match the schema, the operation fails right there, so bad data never makes it into the rest of the
app.</p>

<p>Here’s a schema for an MSGraph user. We only need a few fields.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/msgraph/schemas.ts</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Schema</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">effect</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">MSGraphUser</span> <span class="o">=</span> <span class="nx">Schema</span><span class="p">.</span><span class="nx">Struct</span><span class="p">({</span>
  <span class="dl">'</span><span class="s1">@odata.type</span><span class="dl">'</span><span class="p">:</span> <span class="nx">Schema</span><span class="p">.</span><span class="nb">String</span><span class="p">,</span>
  <span class="dl">'</span><span class="s1">id</span><span class="dl">'</span><span class="p">:</span> <span class="nx">Schema</span><span class="p">.</span><span class="nb">String</span><span class="p">,</span>
  <span class="dl">'</span><span class="s1">displayName</span><span class="dl">'</span><span class="p">:</span> <span class="nx">Schema</span><span class="p">.</span><span class="nb">String</span><span class="p">,</span>
<span class="p">});</span>

<span class="k">export</span> <span class="kd">type</span> <span class="nx">MSGraphUser</span> <span class="o">=</span> <span class="nx">Schema</span><span class="p">.</span><span class="nx">Schema</span><span class="p">.</span><span class="nx">Type</span><span class="o">&lt;</span><span class="k">typeof</span> <span class="nx">MSGraphUser</span><span class="o">&gt;</span><span class="p">;</span>
</code></pre></div></div>

<p>The Graph API returns a lot of its lists in pages, so we also need a schema for a paginated response. Since the items
in the page can be anything, we write a function that builds the schema for us:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/msgraph/schemas.ts</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">PagedResponse</span> <span class="o">=</span> <span class="o">&lt;</span><span class="nx">A</span><span class="p">,</span> <span class="nx">I</span><span class="p">,</span> <span class="nx">R</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">schema</span><span class="p">:</span> <span class="nx">Schema</span><span class="p">.</span><span class="nx">Schema</span><span class="o">&lt;</span><span class="nx">A</span><span class="p">,</span> <span class="nx">I</span><span class="p">,</span> <span class="nx">R</span><span class="o">&gt;</span><span class="p">)</span> <span class="o">=&gt;</span>
  <span class="nx">Schema</span><span class="p">.</span><span class="nx">Struct</span><span class="p">({</span>
    <span class="dl">'</span><span class="s1">@odata.nextLink</span><span class="dl">'</span><span class="p">:</span> <span class="nx">Schema</span><span class="p">.</span><span class="nx">optional</span><span class="p">(</span><span class="nx">Schema</span><span class="p">.</span><span class="nb">String</span><span class="p">),</span>
    <span class="dl">'</span><span class="s1">value</span><span class="dl">'</span><span class="p">:</span> <span class="nx">Schema</span><span class="p">.</span><span class="nb">Array</span><span class="p">(</span><span class="nx">schema</span><span class="p">),</span>
  <span class="p">});</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">PagedResponse</code> takes a schema for one item and returns a schema for a page of those items. We’ll use it in a moment.</p>

<h3 id="a-custom-error-type">A custom error type</h3>

<p>Instead of throwing generic errors, we give the client its own error type. Effect’s <code class="language-plaintext highlighter-rouge">Data.TaggedError</code> makes this
easy.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/msgraph/errors.ts</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Data</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">effect</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">class</span> <span class="nx">MSGraphError</span> <span class="kd">extends</span> <span class="nx">Data</span><span class="p">.</span><span class="nx">TaggedError</span><span class="p">(</span><span class="dl">'</span><span class="s1">@support/MSGraphError</span><span class="dl">'</span><span class="p">)</span><span class="o">&lt;</span><span class="p">{</span>
  <span class="k">readonly</span> <span class="na">error</span><span class="p">:</span> <span class="nx">unknown</span><span class="p">;</span>
  <span class="k">readonly</span> <span class="nx">message</span><span class="p">?:</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">}</span><span class="o">&gt;</span> <span class="p">{}</span>
</code></pre></div></div>

<p>A tagged error carries a label (the “tag”, here <code class="language-plaintext highlighter-rouge">@support/MSGraphError</code>). Effect can match on that tag, so callers can
handle this specific failure without catching everything else by accident.</p>

<h2 id="building-the-client">Building the client</h2>

<p>Now we can write the client functions. Each one returns an <code class="language-plaintext highlighter-rouge">Effect</code> that describes a call to the MSGraph API.</p>

<h3 id="authentication">Authentication</h3>

<p>First we need an access token. We use the OAuth client credentials flow, which is the one meant for an app talking to
an API on its own, with no user signing in. We send a <code class="language-plaintext highlighter-rouge">POST</code> request with the app’s credentials to the Microsoft
identity platform and get a token back.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/msgraph/client.ts</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">HttpClient</span><span class="p">,</span> <span class="nx">HttpClientRequest</span><span class="p">,</span> <span class="nx">HttpClientResponse</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@effect/platform</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Effect</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">effect</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">MSGraphError</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">~/msgraph/errors</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">AccessTokenResponse</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">~/msgraph/schemas</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">authenticate</span> <span class="o">=</span> <span class="p">(</span>
  <span class="nx">tenantId</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
  <span class="nx">clientId</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
  <span class="nx">clientSecret</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
  <span class="nx">scope</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
<span class="p">)</span> <span class="o">=&gt;</span>
  <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">httpClient</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">HttpClient</span><span class="p">.</span><span class="nx">HttpClient</span><span class="p">;</span>
    <span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="s2">`https://login.microsoftonline.com/</span><span class="p">${</span><span class="nx">tenantId</span><span class="p">}</span><span class="s2">/oauth2/v2.0/token`</span><span class="p">;</span>

    <span class="kd">const</span> <span class="nx">request</span> <span class="o">=</span> <span class="nx">HttpClientRequest</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="nx">url</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
      <span class="nx">HttpClientRequest</span><span class="p">.</span><span class="nx">bodyUrlParams</span><span class="p">({</span>
        <span class="na">client_id</span><span class="p">:</span> <span class="nx">clientId</span><span class="p">,</span>
        <span class="na">client_secret</span><span class="p">:</span> <span class="nx">clientSecret</span><span class="p">,</span>
        <span class="na">scope</span><span class="p">:</span> <span class="nx">scope</span><span class="p">,</span>
        <span class="na">grant_type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">client_credentials</span><span class="dl">'</span><span class="p">,</span>
      <span class="p">}),</span>
    <span class="p">);</span>

    <span class="k">return</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">httpClient</span><span class="p">.</span><span class="nx">execute</span><span class="p">(</span><span class="nx">request</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
      <span class="nx">Effect</span><span class="p">.</span><span class="nx">flatMap</span><span class="p">(</span><span class="nx">HttpClientResponse</span><span class="p">.</span><span class="nx">filterStatusOk</span><span class="p">),</span>
      <span class="nx">Effect</span><span class="p">.</span><span class="nx">flatMap</span><span class="p">(</span><span class="nx">HttpClientResponse</span><span class="p">.</span><span class="nx">schemaBodyJson</span><span class="p">(</span><span class="nx">AccessTokenResponse</span><span class="p">)),</span>
      <span class="nx">Effect</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">response</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">response</span><span class="p">.</span><span class="nx">access_token</span><span class="p">),</span>
    <span class="p">);</span>
  <span class="p">}).</span><span class="nx">pipe</span><span class="p">(</span>
    <span class="nx">Effect</span><span class="p">.</span><span class="nx">mapError</span><span class="p">((</span><span class="nx">error</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">new</span> <span class="nx">MSGraphError</span><span class="p">({</span> <span class="na">message</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Failed to authenticate</span><span class="dl">'</span><span class="p">,</span> <span class="na">error</span><span class="p">:</span> <span class="nx">error</span> <span class="p">})),</span>
  <span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Effect.gen</code> lets us write this with generator functions (<code class="language-plaintext highlighter-rouge">function*</code> and <code class="language-plaintext highlighter-rouge">yield*</code>), which reads a lot like
<code class="language-plaintext highlighter-rouge">async/await</code>. Here’s what happens, step by step:</p>

<ol>
  <li>We get the default <code class="language-plaintext highlighter-rouge">HttpClient</code> from the Effect context. The context is where Effect keeps dependencies like this,
so we don’t have to pass them around by hand.</li>
  <li>We build a <code class="language-plaintext highlighter-rouge">POST</code> request. <code class="language-plaintext highlighter-rouge">HttpClientRequest.bodyUrlParams</code> formats the body the way the token endpoint expects.</li>
  <li>We <code class="language-plaintext highlighter-rouge">execute</code> the request and pipe the result through a few steps.</li>
  <li><code class="language-plaintext highlighter-rouge">filterStatusOk</code> makes the effect fail if the HTTP status isn’t in the 200-299 range.</li>
  <li><code class="language-plaintext highlighter-rouge">schemaBodyJson(AccessTokenResponse)</code> parses the JSON body with the <code class="language-plaintext highlighter-rouge">AccessTokenResponse</code> schema. This is where the
validation happens.</li>
  <li><code class="language-plaintext highlighter-rouge">map</code> pulls the <code class="language-plaintext highlighter-rouge">access_token</code> out of the parsed response.</li>
  <li><code class="language-plaintext highlighter-rouge">mapError</code> wraps any failure in our <code class="language-plaintext highlighter-rouge">MSGraphError</code>, with a message that says what we were trying to do.</li>
</ol>

<h3 id="fetching-group-members">Fetching group members</h3>

<p>With a token in hand, we can fetch the members of a group. The Graph API has endpoints for direct members and for
transitive (nested) members. The code is nearly the same for both, so here’s the direct one.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/msgraph/client.ts</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">getDirectGroupMembers</span> <span class="o">=</span> <span class="p">(</span><span class="nx">authToken</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">groupId</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="o">=&gt;</span>
  <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">httpClient</span> <span class="o">=</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">HttpClient</span><span class="p">.</span><span class="nx">HttpClient</span><span class="p">;</span>
    <span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="s2">`https://graph.microsoft.com/v1.0/groups/</span><span class="p">${</span><span class="nx">groupId</span><span class="p">}</span><span class="s2">/members?$top=999`</span><span class="p">;</span>

    <span class="kd">const</span> <span class="nx">request</span> <span class="o">=</span> <span class="nx">HttpClientRequest</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="nx">url</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
      <span class="nx">HttpClientRequest</span><span class="p">.</span><span class="nx">bearerToken</span><span class="p">(</span><span class="nx">authToken</span><span class="p">)</span>
    <span class="p">);</span>

    <span class="k">return</span> <span class="k">yield</span><span class="o">*</span> <span class="nx">httpClient</span><span class="p">.</span><span class="nx">execute</span><span class="p">(</span><span class="nx">request</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
      <span class="nx">Effect</span><span class="p">.</span><span class="nx">flatMap</span><span class="p">(</span><span class="nx">HttpClientResponse</span><span class="p">.</span><span class="nx">filterStatusOk</span><span class="p">),</span>
      <span class="nx">Effect</span><span class="p">.</span><span class="nx">flatMap</span><span class="p">(</span><span class="nx">HttpClientResponse</span><span class="p">.</span><span class="nx">schemaBodyJson</span><span class="p">(</span><span class="nx">PagedResponse</span><span class="p">(</span><span class="nx">MSGraphUser</span><span class="p">))),</span>
      <span class="nx">Effect</span><span class="p">.</span><span class="nx">map</span><span class="p">(({</span> <span class="nx">value</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="nx">value</span><span class="p">.</span><span class="nx">filter</span><span class="p">((</span><span class="nx">member</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">member</span><span class="p">[</span><span class="dl">'</span><span class="s1">@odata.type</span><span class="dl">'</span><span class="p">]</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">#microsoft.graph.user</span><span class="dl">'</span><span class="p">)),</span>
    <span class="p">);</span>
  <span class="p">}).</span><span class="nx">pipe</span><span class="p">(</span>
    <span class="nx">Effect</span><span class="p">.</span><span class="nx">mapError</span><span class="p">((</span><span class="nx">error</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">new</span> <span class="nx">MSGraphError</span><span class="p">({</span> <span class="na">message</span><span class="p">:</span> <span class="s2">`Failed to fetch members for group </span><span class="p">${</span><span class="nx">groupId</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span> <span class="na">error</span><span class="p">:</span> <span class="nx">error</span> <span class="p">})),</span>
  <span class="p">);</span>
</code></pre></div></div>

<p>We parse the list of members with <code class="language-plaintext highlighter-rouge">PagedResponse(MSGraphUser)</code>. Then we filter it so we only return users, because a
group can also contain things like devices or service principals (app identities).</p>

<h4 id="what-about-pagination">What about pagination?</h4>

<p>To keep things simple, this function asks for a single large page with <code class="language-plaintext highlighter-rouge">$top=999</code>. That means it’s incomplete for
groups with more than 999 members. A production client would check the response for <code class="language-plaintext highlighter-rouge">@odata.nextLink</code> and keep fetching
until there are no more pages. Effect has tools like <code class="language-plaintext highlighter-rouge">Effect.iterate</code> that would handle this nicely, but we left it out
here.</p>

<h3 id="changing-group-membership-in-bulk">Changing group membership in bulk</h3>

<p>Adding or removing hundreds of users one at a time is slow. It’s better to batch the work, and there are two common ways
to do it: send several requests in parallel, or use the JSON batching endpoint. We use one for each.</p>

<h4 id="adding-members-with-parallel-requests">Adding members with parallel requests</h4>

<p>To add members, we send a <code class="language-plaintext highlighter-rouge">PATCH</code> request with the <code class="language-plaintext highlighter-rouge">members@odata.bind</code> property. The API lets you add up to 20 members
in one request, so we split the user list into chunks of 20 and send all the requests in parallel.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/msgraph/client.ts</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Chunk</span><span class="p">,</span> <span class="nx">Effect</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">effect</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">MAX_REQUESTS_PER_BATCH</span> <span class="o">=</span> <span class="mi">20</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">addMembersToGroup</span> <span class="o">=</span> <span class="p">(</span><span class="nx">authToken</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">groupId</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">users</span><span class="p">:</span> <span class="nx">MSGraphUser</span><span class="p">[])</span> <span class="o">=&gt;</span>
  <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
    <span class="c1">// ... setup httpClient and url</span>

    <span class="kd">const</span> <span class="nx">userChunks</span> <span class="o">=</span> <span class="nx">Chunk</span><span class="p">.</span><span class="nx">chunksOf</span><span class="p">(</span><span class="nx">Chunk</span><span class="p">.</span><span class="nx">fromIterable</span><span class="p">(</span><span class="nx">users</span><span class="p">),</span> <span class="nx">MAX_REQUESTS_PER_BATCH</span><span class="p">);</span>

    <span class="kd">const</span> <span class="nx">effects</span> <span class="o">=</span> <span class="nx">userChunks</span><span class="p">.</span><span class="nx">pipe</span><span class="p">(</span>
      <span class="nx">Chunk</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">userChunk</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">request</span> <span class="o">=</span> <span class="nx">HttpClientRequest</span><span class="p">.</span><span class="nx">patch</span><span class="p">(</span><span class="nx">url</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
          <span class="nx">HttpClientRequest</span><span class="p">.</span><span class="nx">bearerToken</span><span class="p">(</span><span class="nx">authToken</span><span class="p">),</span>
          <span class="nx">HttpClientRequest</span><span class="p">.</span><span class="nx">bodyUnsafeJson</span><span class="p">({</span>
            <span class="dl">'</span><span class="s1">members@odata.bind</span><span class="dl">'</span><span class="p">:</span> <span class="nx">Chunk</span><span class="p">.</span><span class="nx">toArray</span><span class="p">(</span><span class="nx">userChunk</span><span class="p">).</span><span class="nx">map</span><span class="p">(</span><span class="nx">user</span> <span class="o">=&gt;</span> <span class="s2">`https://graph.microsoft.com/v1.0/directoryObjects/</span><span class="p">${</span><span class="nx">user</span><span class="p">.</span><span class="nx">id</span><span class="p">}</span><span class="s2">`</span><span class="p">)</span>
          <span class="p">}),</span>
        <span class="p">);</span>
        <span class="k">return</span> <span class="nx">httpClient</span><span class="p">.</span><span class="nx">execute</span><span class="p">(</span><span class="nx">request</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
          <span class="nx">Effect</span><span class="p">.</span><span class="nx">flatMap</span><span class="p">(</span><span class="nx">HttpClientResponse</span><span class="p">.</span><span class="nx">filterStatusOk</span><span class="p">)</span>
        <span class="p">);</span>
      <span class="p">})</span>
    <span class="p">);</span>

    <span class="k">yield</span><span class="o">*</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">all</span><span class="p">(</span><span class="nx">effects</span><span class="p">,</span> <span class="p">{</span> <span class="na">concurrency</span><span class="p">:</span> <span class="dl">'</span><span class="s1">inherit</span><span class="dl">'</span> <span class="p">});</span>
  <span class="p">}).</span><span class="nx">pipe</span><span class="p">(</span>
    <span class="nx">Effect</span><span class="p">.</span><span class="nx">mapError</span><span class="p">((</span><span class="nx">error</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">new</span> <span class="nx">MSGraphError</span><span class="p">({</span> <span class="na">message</span><span class="p">:</span> <span class="s2">`Failed to add members to group`</span><span class="p">,</span> <span class="na">error</span><span class="p">:</span> <span class="nx">error</span> <span class="p">})),</span>
  <span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Chunk</code> is one of Effect’s immutable data structures, and <code class="language-plaintext highlighter-rouge">Chunk.chunksOf</code> splits the users into groups of 20. We turn
each group into an <code class="language-plaintext highlighter-rouge">Effect</code> that makes the API call. <code class="language-plaintext highlighter-rouge">Effect.all</code> then runs them all concurrently and waits for every
one to succeed. If any single request fails, the whole operation fails.</p>

<h4 id="removing-members-with-json-batching">Removing members with JSON batching</h4>

<p>To remove members, we use the <code class="language-plaintext highlighter-rouge">$batch</code> endpoint. It lets us bundle up to 20 separate <code class="language-plaintext highlighter-rouge">DELETE</code> operations into one
<code class="language-plaintext highlighter-rouge">POST</code> request, which is the most efficient way to do a lot of deletions.</p>

<p>The tricky part is the response. The batch request itself can come back <code class="language-plaintext highlighter-rouge">200 OK</code> even when some of the operations
<em>inside</em> it failed. So we have to look at the response body to know whether everything actually worked.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/msgraph/client.ts</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">removeMembersFromGroup</span> <span class="o">=</span> <span class="p">(</span><span class="nx">authToken</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">groupId</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">users</span><span class="p">:</span> <span class="nx">MSGraphUser</span><span class="p">[])</span> <span class="o">=&gt;</span>
  <span class="nx">Effect</span><span class="p">.</span><span class="nx">gen</span><span class="p">(</span><span class="kd">function</span><span class="o">*</span> <span class="p">()</span> <span class="p">{</span>
    <span class="c1">// ... setup httpClient and url</span>

    <span class="kd">const</span> <span class="nx">requests</span> <span class="o">=</span> <span class="nx">users</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">user</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span>
      <span class="na">id</span><span class="p">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span>
      <span class="na">method</span><span class="p">:</span> <span class="dl">'</span><span class="s1">DELETE</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">url</span><span class="p">:</span> <span class="s2">`/groups/</span><span class="p">${</span><span class="nx">groupId</span><span class="p">}</span><span class="s2">/members/</span><span class="p">${</span><span class="nx">user</span><span class="p">.</span><span class="nx">id</span><span class="p">}</span><span class="s2">/$ref`</span><span class="p">,</span>
    <span class="p">}));</span>

    <span class="kd">const</span> <span class="nx">requestChunks</span> <span class="o">=</span> <span class="nx">Chunk</span><span class="p">.</span><span class="nx">chunksOf</span><span class="p">(</span><span class="nx">Chunk</span><span class="p">.</span><span class="nx">fromIterable</span><span class="p">(</span><span class="nx">requests</span><span class="p">),</span> <span class="nx">MAX_REQUESTS_PER_BATCH</span><span class="p">);</span>

    <span class="kd">const</span> <span class="nx">effects</span> <span class="o">=</span> <span class="nx">requestChunks</span><span class="p">.</span><span class="nx">pipe</span><span class="p">(</span>
      <span class="nx">Chunk</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">requestChunk</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">request</span> <span class="o">=</span> <span class="nx">HttpClientRequest</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">https://graph.microsoft.com/v1.0/$batch</span><span class="dl">'</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
          <span class="nx">HttpClientRequest</span><span class="p">.</span><span class="nx">bearerToken</span><span class="p">(</span><span class="nx">authToken</span><span class="p">),</span>
          <span class="nx">HttpClientRequest</span><span class="p">.</span><span class="nx">bodyUnsafeJson</span><span class="p">({</span> <span class="na">requests</span><span class="p">:</span> <span class="nx">Chunk</span><span class="p">.</span><span class="nx">toArray</span><span class="p">(</span><span class="nx">requestChunk</span><span class="p">)</span> <span class="p">}),</span>
        <span class="p">);</span>

        <span class="k">return</span> <span class="nx">httpClient</span><span class="p">.</span><span class="nx">execute</span><span class="p">(</span><span class="nx">request</span><span class="p">).</span><span class="nx">pipe</span><span class="p">(</span>
          <span class="nx">Effect</span><span class="p">.</span><span class="nx">flatMap</span><span class="p">(</span><span class="nx">HttpClientResponse</span><span class="p">.</span><span class="nx">filterStatusOk</span><span class="p">),</span>
          <span class="nx">Effect</span><span class="p">.</span><span class="nx">flatMap</span><span class="p">(</span><span class="nx">HttpClientResponse</span><span class="p">.</span><span class="nx">schemaBodyJson</span><span class="p">(</span><span class="nx">BatchResponse</span><span class="p">)),</span>
          <span class="nx">Effect</span><span class="p">.</span><span class="nx">flatMap</span><span class="p">(({</span> <span class="nx">responses</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
            <span class="kd">const</span> <span class="nx">failures</span> <span class="o">=</span> <span class="nx">responses</span><span class="p">.</span><span class="nx">filter</span><span class="p">((</span><span class="nx">response</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">response</span><span class="p">.</span><span class="nx">status</span> <span class="o">&gt;=</span> <span class="mi">400</span><span class="p">);</span>
            <span class="k">return</span> <span class="nx">failures</span><span class="p">.</span><span class="nx">length</span> <span class="o">===</span> <span class="mi">0</span> <span class="p">?</span> <span class="nx">Effect</span><span class="p">.</span><span class="k">void</span> <span class="p">:</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">fail</span><span class="p">(</span><span class="nx">failures</span><span class="p">);</span>
          <span class="p">}),</span>
        <span class="p">);</span>
      <span class="p">})</span>
    <span class="p">);</span>

    <span class="k">yield</span><span class="o">*</span> <span class="nx">Effect</span><span class="p">.</span><span class="nx">all</span><span class="p">(</span><span class="nx">effects</span><span class="p">,</span> <span class="p">{</span> <span class="na">concurrency</span><span class="p">:</span> <span class="dl">'</span><span class="s1">inherit</span><span class="dl">'</span> <span class="p">});</span>
  <span class="p">}).</span><span class="nx">pipe</span><span class="p">(</span>
    <span class="nx">Effect</span><span class="p">.</span><span class="nx">mapError</span><span class="p">((</span><span class="nx">error</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">new</span> <span class="nx">MSGraphError</span><span class="p">({</span> <span class="na">message</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Failed to remove members from group</span><span class="dl">'</span><span class="p">,</span> <span class="na">error</span><span class="p">:</span> <span class="nx">error</span> <span class="p">})),</span>
  <span class="p">);</span>
</code></pre></div></div>

<p>This is the most involved function, and it’s where Effect earns its keep. The important bit is the last <code class="language-plaintext highlighter-rouge">flatMap</code>,
after we parse the batch response. It checks whether any of the inner responses has an error status (400 or higher).
If one does, we fail the effect on purpose with <code class="language-plaintext highlighter-rouge">Effect.fail</code>. If not, we succeed with <code class="language-plaintext highlighter-rouge">Effect.void</code>. So if
<code class="language-plaintext highlighter-rouge">removeMembersFromGroup</code> succeeds, we know every user was actually removed.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>That’s a small MSGraph client. With Effect, the async calls, response validation, and error handling are all split into
small pieces that fit together, and the types tell you what each piece returns and how it can fail.</p>

<p>Effect does take some getting used to. Thinking in effects instead of promises is a real shift. But for code with a lot
of async work, like this client, we think it’s worth it. The code is easier to reason about, handles failure better,
and is easier to maintain. That’s especially true for the hard parts, like batching and error handling.</p>]]></content><author><name>Greg Baker</name></author><category term="effect-ts" /><category term="msgraph" /><category term="typescript" /><summary type="html"><![CDATA[Calling an external API sounds simple until you have to deal with network failures, responses that don’t look like you expected, and a pile of async code. The Microsoft Graph API (MSGraph) is no different. It has its own rules too, like limits on how many operations you can batch together and a specific way to authenticate.]]></summary></entry></feed>