<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ismail ZAHIR</title>
    <description>The latest articles on DEV Community by Ismail ZAHIR (@ismailzahir).</description>
    <link>https://dev.to/ismailzahir</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4047894%2Fd92e0840-d13b-44ec-93ad-cf01d8600704.jpg</url>
      <title>DEV Community: Ismail ZAHIR</title>
      <link>https://dev.to/ismailzahir</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ismailzahir"/>
    <language>en</language>
    <item>
      <title>The Hidden Cost of “Just Add a Setting”</title>
      <dc:creator>Ismail ZAHIR</dc:creator>
      <pubDate>Wed, 09 Sep 2026 12:09:57 +0000</pubDate>
      <link>https://dev.to/ismailzahir/the-hidden-cost-of-just-add-a-setting-1kbe</link>
      <guid>https://dev.to/ismailzahir/the-hidden-cost-of-just-add-a-setting-1kbe</guid>
      <description>&lt;p&gt;“Can we just add a setting for this?”&lt;/p&gt;

&lt;p&gt;It sounds harmless.&lt;/p&gt;

&lt;p&gt;Suppose an office should be able to choose its default appointment duration. The first implementation is obvious:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Office&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;appointmentDuration&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set it to &lt;code&gt;30&lt;/code&gt;, expose it through the API, add an input in the frontend, and move on.&lt;/p&gt;

&lt;p&gt;But now consider two offices that both currently use 30 minutes.&lt;/p&gt;

&lt;p&gt;For the first office, 30 is explicitly configured. For the second, there is no configured value at all; it simply inherits the platform default of 30.&lt;/p&gt;

&lt;p&gt;Those offices behave identically today.&lt;/p&gt;

&lt;p&gt;They may behave differently tomorrow.&lt;/p&gt;

&lt;p&gt;If the platform default changes to 20 minutes, the first office should probably stay at 30 while the second should inherit 20.&lt;/p&gt;

&lt;p&gt;That distinction — &lt;strong&gt;explicit value versus effective value&lt;/strong&gt; — is where a seemingly simple setting starts becoming a modeling problem.&lt;/p&gt;

&lt;p&gt;It leads directly to another subtle question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What should “Reset to default” actually do?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It should usually remove the override, not write the current default into it. Writing &lt;code&gt;30&lt;/code&gt; and inheriting &lt;code&gt;30&lt;/code&gt; happen to produce the same result today, but they represent different intent.&lt;/p&gt;

&lt;p&gt;That is the kind of detail most generic settings-table designs miss.&lt;/p&gt;

&lt;p&gt;This article follows one setting, &lt;code&gt;APPOINTMENT_DURATION&lt;/code&gt;, from a field on an entity to a scoped, resolved, validated, audited, and cached configuration model — and looks at the design decisions that appear along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  A simple field is often the right answer
&lt;/h2&gt;

&lt;p&gt;Putting configuration directly on an entity is not inherently bad.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Office&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;onlineBookingEnabled&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;appointmentDuration&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For stable properties with obvious ownership, this design is excellent. It gives you strong typing, database constraints, simple queries, straightforward refactoring, and very little infrastructure.&lt;/p&gt;

&lt;p&gt;The problem starts when those values stop being ordinary properties and acquire behavior: platform defaults, tenant overrides, office overrides, runtime modification, authorization rules, dynamic frontend rendering, validation metadata, audit requirements, or lifecycle rules.&lt;/p&gt;

&lt;p&gt;At that point, configuration has concepts of its own, and those concepts need somewhere to live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Definition and value are different concepts
&lt;/h2&gt;

&lt;p&gt;Once settings become dynamic, I find it useful to separate two ideas.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;setting definition&lt;/strong&gt; identifies a setting and records the persisted metadata the application needs to manage its lifecycle and presentation.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;setting value&lt;/strong&gt; records an explicit override for a particular owner.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SettingDefinition&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unique&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Enumerated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnumType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STRING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;SettingDataType&lt;/span&gt; &lt;span class="n"&gt;dataType&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Enumerated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnumType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STRING&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;SettingStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;SettingDataType&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;STRING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;BOOLEAN&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;INTEGER&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;DECIMAL&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;ENUM&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;JSON&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;SettingStatus&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;ACTIVE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;DEPRECATED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;DISABLED&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That entity is deliberately not the complete behavioral specification of the setting.&lt;/p&gt;

&lt;p&gt;In the model I prefer, &lt;strong&gt;code owns behavioral metadata&lt;/strong&gt; such as defaults, validation constraints, and permitted override scopes. The database owns persistent identity, lifecycle status, and presentation metadata such as labels or localizable descriptions.&lt;/p&gt;

&lt;p&gt;So the canonical code-level specification might be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;APPOINTMENT_DURATION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;integer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"APPOINTMENT_DURATION"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;minimum&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maximum&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allowOverrideAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;SettingScope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TENANT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;SettingScope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;OFFICE&lt;/span&gt;
        &lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives the application one authoritative place for the rules that affect execution.&lt;/p&gt;

&lt;p&gt;The persisted definition still matters. It gives values and audit records a stable database identity, supports lifecycle management, and can carry presentation metadata without turning database rows into executable business rules.&lt;/p&gt;

&lt;p&gt;At startup, the application can validate that the persisted definition is compatible with the code specification. If code says &lt;code&gt;INTEGER&lt;/code&gt; while the database says &lt;code&gt;STRING&lt;/code&gt;, deployment should fail before the application starts serving requests.&lt;/p&gt;

&lt;p&gt;That separation avoids an awkward situation where the same rule exists independently in Java and in a mutable database row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ownership is not one field
&lt;/h2&gt;

&lt;p&gt;A common generic model puts this on the definition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;SettingOwnerType&lt;/span&gt; &lt;span class="n"&gt;ownerType&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is coherent only if a setting belongs to exactly one level.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;APPOINTMENT_DURATION&lt;/code&gt; can exist only at office level, &lt;code&gt;ownerType = OFFICE&lt;/code&gt; is fine.&lt;/p&gt;

&lt;p&gt;But it does not describe a hierarchy such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user
  ↓
office
  ↓
organization
  ↓
tenant
  ↓
platform default

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the same setting can be overridden at several levels, its specification needs to express &lt;strong&gt;where overrides are permitted&lt;/strong&gt;, not pretend it has one owner.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;SettingScope&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;TENANT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;ORGANIZATION&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;OFFICE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;USER&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our appointment-duration setting permits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TENANT
OFFICE

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The platform default comes from the canonical specification. A tenant may override it. An office may override the tenant. A user may not.&lt;/p&gt;

&lt;p&gt;Another setting might allow only tenant overrides. A security-sensitive setting might allow no business-level overrides at all.&lt;/p&gt;

&lt;p&gt;That distinction becomes important once authorization enters the picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resolution needs a context, not a growing method signature
&lt;/h2&gt;

&lt;p&gt;Once hierarchy exists, the resolver needs to know which tenant, organization, office, or user is involved.&lt;/p&gt;

&lt;p&gt;I would avoid this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;officeId&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It bakes today's hierarchy into the public API. Add organizations later and every consumer changes.&lt;/p&gt;

&lt;p&gt;A better boundary is a context object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;SettingContext&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;organizationId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;officeId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The internal resolver can then operate on that context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;SettingResolver&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ResolvedSetting&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;SettingContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Business code should normally depend on a small &lt;code&gt;Settings&lt;/code&gt; facade rather than directly on the resolver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Settings&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;getInteger&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;SettingContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;getBoolean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;SettingContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The layering is intentional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Business services
      ↓
Settings facade
      ↓
SettingResolver
      ↓
Persistence / cache / hierarchy

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The facade gives consumers a convenient typed API. The resolver owns precedence, source tracking, caching, and persistence details.&lt;/p&gt;

&lt;p&gt;Business services should never reimplement inheritance themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Explicit value and effective value are different data
&lt;/h2&gt;

&lt;p&gt;Suppose the platform default is 30 and an office has no override.&lt;/p&gt;

&lt;p&gt;Returning this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;throws away important information.&lt;/p&gt;

&lt;p&gt;The frontend cannot know whether 30 was explicitly configured or inherited.&lt;/p&gt;

&lt;p&gt;A better resolved model is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ResolvedSetting&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;T&lt;/span&gt; &lt;span class="n"&gt;explicitValue&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;T&lt;/span&gt; &lt;span class="n"&gt;effectiveValue&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;SettingSource&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;overridden&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An office with no override might resolve to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"APPOINTMENT_DURATION"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"explicitValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"effectiveValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PLATFORM_DEFAULT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"overridden"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If its tenant defines 20:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"APPOINTMENT_DURATION"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"explicitValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"effectiveValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TENANT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"overridden"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And after the office chooses 45:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"APPOINTMENT_DURATION"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"explicitValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"effectiveValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OFFICE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"overridden"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not just frontend convenience. It exposes the actual semantics of the configuration model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reset is a first-class domain operation
&lt;/h2&gt;

&lt;p&gt;Suppose the platform default is currently 30 and an office clicks &lt;strong&gt;Reset to default&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A naive implementation might do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;settingValue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"30"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That does not reset anything.&lt;/p&gt;

&lt;p&gt;It creates an explicit office override equal to today's default.&lt;/p&gt;

&lt;p&gt;If the platform changes the default to 20 next month, that office stays on 30 because its value is now pinned.&lt;/p&gt;

&lt;p&gt;If reset means “inherit again,” the operation must remove the explicit override.&lt;/p&gt;

&lt;p&gt;But that does &lt;strong&gt;not&lt;/strong&gt; mean the UI or business service should call the repository directly.&lt;/p&gt;

&lt;p&gt;Reset has the same concerns as any other configuration mutation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  authorization;&lt;/li&gt;
&lt;li&gt;  auditing;&lt;/li&gt;
&lt;li&gt;  cache invalidation;&lt;/li&gt;
&lt;li&gt;  lifecycle rules;&lt;/li&gt;
&lt;li&gt;  events or downstream side effects.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So it deserves an application operation of its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;removeOverride&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;SettingContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;authorization&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;checkCanOverride&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;SettingValue&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;valueRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findOverride&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;
        &lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;orElse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;valueRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;auditService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;recordOverrideRemoved&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;cacheVersions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bump&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;eventPublisher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SettingChangedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;
        &lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that operation, normal resolution takes over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;office override: none
tenant override: none
platform default: 30

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the platform later changes to 20, the office automatically sees 20.&lt;/p&gt;

&lt;p&gt;This is why absence is not merely missing data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absence can represent inheritance.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And reset is not just “delete instead of write.”&lt;/p&gt;

&lt;p&gt;It is a first-class domain operation that restores inheritance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persisting polymorphic owners has real trade-offs
&lt;/h2&gt;

&lt;p&gt;The domain model may be conceptually simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;definition + scope + owner + value

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;but the relational model still needs to represent that safely.&lt;/p&gt;

&lt;p&gt;The generic approach is attractive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;setting_values
-------------------------
definition_id
owner_type
owner_id
value

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UNIQUE(definition_id, owner_type, owner_id)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is flexible, but &lt;code&gt;owner_id&lt;/code&gt; is polymorphic. The database cannot naturally enforce that an &lt;code&gt;OFFICE&lt;/code&gt; value references &lt;code&gt;offices.id&lt;/code&gt; while a &lt;code&gt;TENANT&lt;/code&gt; value references &lt;code&gt;tenants.id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There are three common approaches.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;generic discriminator table&lt;/strong&gt; keeps the schema compact and extensible but moves referential integrity into application logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate tables per scope&lt;/strong&gt;, such as &lt;code&gt;tenant_setting_values&lt;/code&gt; and &lt;code&gt;office_setting_values&lt;/code&gt;, are more repetitive but give each owner a real foreign key and straightforward uniqueness constraints.&lt;/p&gt;

&lt;p&gt;A third option uses &lt;strong&gt;nullable foreign keys&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;definition_id
tenant_id
organization_id
office_id
user_id
value

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with a check constraint requiring exactly one owner column. That preserves relational integrity but becomes more cumbersome as scopes expand.&lt;/p&gt;

&lt;p&gt;There is no universally correct answer. If ownership levels are few and stable, I value database-enforced referential integrity highly. If scopes really are extensible, a discriminator may be worth the weaker schema guarantees.&lt;/p&gt;

&lt;p&gt;The important point is that a bare &lt;code&gt;UUID ownerId&lt;/code&gt; is not free abstraction. It exchanges schema rigidity for weaker relational guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not every setting should be overridable
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;APPOINTMENT_DURATION&lt;/code&gt; is a reasonable office-level setting.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AUDIT_LOGGING_ENABLED&lt;/code&gt; may not be.&lt;/p&gt;

&lt;p&gt;The configuration model therefore needs to distinguish between visibility and editability.&lt;/p&gt;

&lt;p&gt;Our canonical specs could look conceptually like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APPOINTMENT_DURATION
allowed overrides: TENANT, OFFICE

SECURITY_POLICY_ENABLED
allowed overrides: none

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system must answer two separate questions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What is the effective value?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this actor allowed to create or remove an override at this scope?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The first belongs to resolution.&lt;/p&gt;

&lt;p&gt;The second belongs to authorization.&lt;/p&gt;

&lt;p&gt;A setting being visible does not imply it should be editable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation belongs near the specification
&lt;/h2&gt;

&lt;p&gt;Without a shared model, validation tends to spread through service code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"APPOINTMENT_DURATION"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseInt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidSettingValueException&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then another setting adds another branch.&lt;/p&gt;

&lt;p&gt;Then another.&lt;/p&gt;

&lt;p&gt;The canonical specification gives structural validation a proper home:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;integer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"APPOINTMENT_DURATION"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;minimum&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maximum&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple constraints such as type, minimum, maximum, allowed enum values, or string patterns can be enforced generically.&lt;/p&gt;

&lt;p&gt;A setting also does not necessarily need a default. If the absence of a value is itself meaningful, the specification can explicitly model that instead of inventing a synthetic default just to satisfy the framework.&lt;/p&gt;

&lt;p&gt;But not every rule belongs in the specification.&lt;/p&gt;

&lt;p&gt;If appointment duration must be compatible with a scheduling algorithm, office opening hours, or another setting, that is business logic. A settings framework should centralize structural validation without turning into a homemade rules engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where do definitions come from?
&lt;/h2&gt;

&lt;p&gt;This is one of the most important questions in the entire design.&lt;/p&gt;

&lt;p&gt;Who creates &lt;code&gt;APPOINTMENT_DURATION&lt;/code&gt; in the first place?&lt;/p&gt;

&lt;h3&gt;
  
  
  Database-first
&lt;/h3&gt;

&lt;p&gt;One option is to seed definitions through Liquibase, Flyway, or another migration system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;setting_definitions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'APPOINTMENT_DURATION'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'INTEGER'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'ACTIVE'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you explicit, reviewable deployment changes. Renames, additions, and lifecycle transitions are visible in migrations.&lt;/p&gt;

&lt;p&gt;The downside is that application code has weaker compile-time knowledge of the available settings unless you create a second representation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code-first
&lt;/h3&gt;

&lt;p&gt;Another approach is to declare the full catalog in source code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;APPOINTMENT_DURATION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;integer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"APPOINTMENT_DURATION"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;minimum&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maximum&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allowOverrideAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;TENANT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;OFFICE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is strongly typed, easy to discover, and easy to test.&lt;/p&gt;

&lt;p&gt;But now you have to decide how database state follows code. Should startup silently insert missing definitions? Delete unknown ones? Modify metadata automatically?&lt;/p&gt;

&lt;p&gt;That can make application startup unexpectedly destructive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hybrid: code owns behavior, migrations own lifecycle
&lt;/h3&gt;

&lt;p&gt;For product-defined settings, I prefer a hybrid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code owns the behavioral contract&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  data type;&lt;/li&gt;
&lt;li&gt;  default value;&lt;/li&gt;
&lt;li&gt;  validation constraints;&lt;/li&gt;
&lt;li&gt;  allowed scopes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Database migrations own persistence and lifecycle&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  creation of the persisted definition;&lt;/li&gt;
&lt;li&gt;  stable database identity;&lt;/li&gt;
&lt;li&gt;  lifecycle state;&lt;/li&gt;
&lt;li&gt;  labels or localization references;&lt;/li&gt;
&lt;li&gt;  explicit renames and migrations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Startup then performs reconciliation as validation rather than mutation.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Code expects:
APPOINTMENT_DURATION / INTEGER

Database contains:
APPOINTMENT_DURATION / STRING

→ deployment fails

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same principle applies to scope or other compatibility-critical metadata.&lt;/p&gt;

&lt;p&gt;If code permits &lt;code&gt;TENANT&lt;/code&gt; and &lt;code&gt;OFFICE&lt;/code&gt;, but persisted metadata implies something incompatible, that should be detected deliberately rather than allowed to drift.&lt;/p&gt;

&lt;p&gt;This avoids having two competing sources of truth: runtime behavior comes from code, while persistence evolution remains an explicit deployment concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catalog joins the two halves
&lt;/h2&gt;

&lt;p&gt;Once definitions are split between code and persistence, the application needs one place that can enumerate them.&lt;/p&gt;

&lt;p&gt;That is the role of a &lt;code&gt;SettingCatalog&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;SettingCatalog&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;Collection&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;findByCode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catalog contains or discovers the registered code-level specifications:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="no"&gt;APPOINTMENT_DURATION&lt;/span&gt;
&lt;span class="no"&gt;ONLINE_BOOKING_ENABLED&lt;/span&gt;
&lt;span class="no"&gt;REMINDER_DELAY&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the application needs a settings page, startup validation, or bulk resolution, it joins those specs with their persisted &lt;code&gt;SettingDefinition&lt;/code&gt; rows.&lt;/p&gt;

&lt;p&gt;That joined view answers questions such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Is this spec &lt;code&gt;ACTIVE&lt;/code&gt;, &lt;code&gt;DEPRECATED&lt;/code&gt;, or &lt;code&gt;DISABLED&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;  What label should the UI display?&lt;/li&gt;
&lt;li&gt;  Does the persisted type still match the canonical spec?&lt;/li&gt;
&lt;li&gt;  Which registered settings should appear in this context?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, the settings page can start from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SettingCatalog
      +
Persisted SettingDefinition metadata
      +
Resolved values
      +
Authorization
      ↓
SettingView

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;DISABLED&lt;/code&gt; definition can therefore remain in the database for historical values and audit records without appearing in the normal configuration UI.&lt;/p&gt;

&lt;p&gt;The catalog is also what makes bulk APIs such as &lt;code&gt;resolveAll(...)&lt;/code&gt; practical: the application has a single enumerable registry of known specs rather than scattered constants with no discovery mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting codes are persistent identifiers
&lt;/h2&gt;

&lt;p&gt;Suppose version one introduces:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SHOW_PATIENT_PHONE&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Later, the feature becomes more nuanced and you replace it with:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PATIENT_CONTACT_VISIBILITY&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That is not a normal rename.&lt;/p&gt;

&lt;p&gt;The original code may already be referenced by stored values, audit records, frontend contracts, support documentation, tests, migration scripts, and external integrations.&lt;/p&gt;

&lt;p&gt;Treating setting codes as API-like identifiers changes how lifecycle should work.&lt;/p&gt;

&lt;p&gt;A safer migration might be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. introduce the new definition
2. migrate existing values
3. mark the old definition DEPRECATED
4. migrate consumers
5. eventually mark it DISABLED

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deleting the old row immediately is often the wrong operation.&lt;/p&gt;

&lt;p&gt;Settings evolve much more like public contracts than ordinary labels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit the decision, not just the current value
&lt;/h2&gt;

&lt;p&gt;Configuration bugs often appear as behavior changes rather than exceptions.&lt;/p&gt;

&lt;p&gt;A support ticket says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Appointments were 30 minutes yesterday. Why are they 45 today?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Looking at the current setting only tells you that the value is 45. It does not tell you why the system changed.&lt;/p&gt;

&lt;p&gt;For behaviorally important settings, an audit trail should be able to answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;setting: APPOINTMENT_DURATION
owner: office-123
old value: inherited 30
new value: 45
changed by: user-456
changed at: 2026-09-09T10:42

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reset should be equally visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;setting: APPOINTMENT_DURATION
owner: office-123
old value: explicit 45
new value: inherited 20
operation: OVERRIDE_REMOVED
changed by: user-456

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For some settings, the historical trail is more valuable than the current row. This is especially true when configuration affects security, pricing, workflow, notifications, or user-visible behavior.&lt;/p&gt;

&lt;p&gt;That is why a setting update or reset can become a real application operation rather than a simple repository mutation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updating a setting may have side effects
&lt;/h2&gt;

&lt;p&gt;A service might eventually coordinate several concerns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;setOverride&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;SettingContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;T&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;authorization&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;checkCanOverride&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;validator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;valueRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;auditService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;recordChange&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;cacheVersions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bump&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;eventPublisher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SettingChangedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;
        &lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generic type matters.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SettingSpec&amp;lt;Integer&amp;gt;&lt;/code&gt; should accept an &lt;code&gt;Integer&lt;/code&gt;, not an arbitrary &lt;code&gt;Object&lt;/code&gt;. If the specification is meant to provide type safety, the mutation API should preserve it all the way through the write path.&lt;/p&gt;

&lt;p&gt;The matching reset operation follows the same lifecycle but removes the explicit value instead of storing a new one.&lt;/p&gt;

&lt;p&gt;Not every setting needs events or cache invalidation. But the architecture should have a natural place for those concerns once they appear.&lt;/p&gt;

&lt;p&gt;Changing &lt;code&gt;DATE_FORMAT&lt;/code&gt; may affect only presentation. Changing &lt;code&gt;ONLINE_BOOKING_ENABLED&lt;/code&gt; may alter behavior immediately. Changing &lt;code&gt;APPOINTMENT_DURATION&lt;/code&gt; may affect future scheduling.&lt;/p&gt;

&lt;p&gt;“Settings” are not necessarily passive data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bulk resolution matters
&lt;/h2&gt;

&lt;p&gt;The typed facade is convenient for business logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInteger&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="no"&gt;APPOINTMENT_DURATION&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But it would be a poor implementation strategy for rendering an entire settings page.&lt;/p&gt;

&lt;p&gt;Imagine 200 definitions across tenant, organization, office, and user scopes. Resolving each one independently can easily create an N+1 query problem.&lt;/p&gt;

&lt;p&gt;The resolver therefore needs a bulk path too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ResolvedSetting&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;resolveAll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Collection&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;specs&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;SettingContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller can obtain those specs from the catalog, filtered by the persisted lifecycle metadata relevant to that operation.&lt;/p&gt;

&lt;p&gt;Internally, the resolver can load each relevant scope in batches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 query → persisted definitions
1 query → tenant overrides
1 query → organization overrides
1 query → office overrides
1 query → user overrides

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and resolve precedence in memory.&lt;/p&gt;

&lt;p&gt;This is an important distinction between a good consumer API and a good persistence strategy. The former may look like individual getters while the latter should remain batch-aware.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration metadata can drive the frontend
&lt;/h2&gt;

&lt;p&gt;Once definitions and resolved values are available, the backend can assemble a dedicated response DTO.&lt;/p&gt;

&lt;p&gt;The constraint portion is intentionally type-dependent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;SettingView&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;SettingDataType&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;T&lt;/span&gt; &lt;span class="n"&gt;explicitValue&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;T&lt;/span&gt; &lt;span class="n"&gt;effectiveValue&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;SettingSource&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;editable&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;constraints&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an integer setting, &lt;code&gt;constraints&lt;/code&gt; might contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minimum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maximum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^[A-Z0-9_-]+$"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an enum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allowedValues"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"EMAIL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"SMS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"NONE"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So an &lt;code&gt;APPOINTMENT_DURATION&lt;/code&gt; response could look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"APPOINTMENT_DURATION"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Appointment duration"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Default duration for appointments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INTEGER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"explicitValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"effectiveValue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TENANT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"constraints"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"minimum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"maximum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This DTO is intentionally different from &lt;code&gt;ResolvedSetting&amp;lt;T&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ResolvedSetting&amp;lt;T&amp;gt;&lt;/code&gt; represents resolution semantics.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SettingView&amp;lt;T&amp;gt;&lt;/code&gt; joins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  canonical specification metadata;&lt;/li&gt;
&lt;li&gt;  persisted definition metadata;&lt;/li&gt;
&lt;li&gt;  resolved values;&lt;/li&gt;
&lt;li&gt;  authorization state;&lt;/li&gt;
&lt;li&gt;  presentation-specific constraints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The frontend can now render the correct control, apply basic validation, indicate inheritance, and decide whether a reset action should be available.&lt;/p&gt;

&lt;p&gt;Dynamic UI is useful, but it also means the setting catalog has become part of the backend/frontend contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make testing cheap
&lt;/h2&gt;

&lt;p&gt;A configuration abstraction becomes painful if every unit test has to create database rows.&lt;/p&gt;

&lt;p&gt;Business code should depend on the small &lt;code&gt;Settings&lt;/code&gt; facade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Settings&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;getInteger&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;SettingContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;getBoolean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;SettingContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A test can then replace it with a simple in-memory implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Settings&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InMemorySettings&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="no"&gt;APPOINTMENT_DURATION&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="mi"&gt;45&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scheduling service should not care whether production obtained 45 from PostgreSQL, Redis, an office override, or a tenant fallback.&lt;/p&gt;

&lt;p&gt;Resolver behavior can be tested separately with focused integration tests covering precedence, reset semantics, disabled definitions, invalid values, and unauthorized overrides.&lt;/p&gt;

&lt;p&gt;A settings framework should reduce coupling, not make configuration a prerequisite for every test fixture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching inherited settings is harder than caching rows
&lt;/h2&gt;

&lt;p&gt;Settings are typically read much more often than they are changed, so caching is attractive.&lt;/p&gt;

&lt;p&gt;Hierarchy complicates invalidation.&lt;/p&gt;

&lt;p&gt;Suppose office A inherits &lt;code&gt;APPOINTMENT_DURATION = 30&lt;/code&gt; from tenant T. The resolved result is cached. Tenant T then changes its value to 20.&lt;/p&gt;

&lt;p&gt;Nobody changed office A, but its cache entry is now stale.&lt;/p&gt;

&lt;p&gt;One practical strategy is to maintain configuration generations per owner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;platform version: 12
tenant T version: 7
office A version: 3

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A cache key for the resolved value can include the relevant generation vector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APPOINTMENT_DURATION
tenant=T:v7
office=A:v3

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the tenant changes, its generation becomes &lt;code&gt;v8&lt;/code&gt;. Old entries no longer match without having to enumerate every descendant office immediately.&lt;/p&gt;

&lt;p&gt;There are other valid cache strategies, but they all need to understand the same inheritance model as the resolver.&lt;/p&gt;

&lt;p&gt;If caching is designed independently from ownership, stale configuration will eventually become a correctness bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Settings are not feature flags
&lt;/h2&gt;

&lt;p&gt;A settings system can look similar to a feature-flag system, but the product semantics are usually different.&lt;/p&gt;

&lt;p&gt;A feature flag is primarily about &lt;strong&gt;rollout and targeting&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Enable the new booking flow for 10% of users.
Enable feature X only in staging.
Enable the redesigned page for internal accounts.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flags are often engineer-owned and intentionally temporary.&lt;/p&gt;

&lt;p&gt;A business setting represents persistent configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;This office uses 45-minute appointments.
This tenant sends reminders 24 hours before a visit.
This organization allows online booking.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Settings are usually ownership-driven and long-lived.&lt;/p&gt;

&lt;p&gt;The underlying infrastructure can overlap, but treating permanent business configuration as disposable feature flags — or temporary rollout flags as permanent settings — creates lifecycle problems later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not all configuration belongs in the settings system
&lt;/h2&gt;

&lt;p&gt;A generic settings subsystem should not become the dumping ground for every configurable value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment configuration&lt;/strong&gt; includes database URLs, service addresses, OAuth credentials, and infrastructure secrets. These generally belong in deployment tooling and secret-management systems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technical application configuration&lt;/strong&gt; includes cache TTLs, scheduler intervals, HTTP limits, or thread-pool sizing. These often belong in application configuration or environment variables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Business configuration&lt;/strong&gt; includes appointment duration, booking policy, office preferences, and reminder behavior. These are the strongest candidates for runtime settings because a business actor owns the decision.&lt;/p&gt;

&lt;p&gt;There is also &lt;strong&gt;sensitive business configuration&lt;/strong&gt;. A tenant-specific SMS sender name may fit naturally into the settings model, while the tenant's SMS provider API key should not be exposed or persisted like an ordinary string. A settings system may store an encrypted value or, preferably where appropriate, a reference to a secret-management system.&lt;/p&gt;

&lt;p&gt;Ownership and sensitivity are separate dimensions. “The tenant controls it” does not mean “store it as plaintext in &lt;code&gt;setting_values&lt;/code&gt;.”&lt;/p&gt;

&lt;h2&gt;
  
  
  The final &lt;code&gt;APPOINTMENT_DURATION&lt;/code&gt; model
&lt;/h2&gt;

&lt;p&gt;Our original field was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;appointmentDuration&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final design looks very different.&lt;/p&gt;

&lt;p&gt;Code declares the behavioral contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;APPOINTMENT_DURATION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;SettingSpec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;integer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"APPOINTMENT_DURATION"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;minimum&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maximum&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;allowOverrideAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;SettingScope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TENANT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;SettingScope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;OFFICE&lt;/span&gt;
        &lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;SettingCatalog&lt;/code&gt; registers that spec and makes it enumerable.&lt;/p&gt;

&lt;p&gt;A migration creates the persisted definition and lifecycle metadata.&lt;/p&gt;

&lt;p&gt;Startup reconciliation verifies that the persisted definition is compatible with the spec.&lt;/p&gt;

&lt;p&gt;An office may store an explicit override:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;definition: APPOINTMENT_DURATION
scope: OFFICE
owner: office-123
value: 45

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Business code asks the facade for the effective value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInteger&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="no"&gt;APPOINTMENT_DURATION&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Internally, the resolver might produce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;explicitValue = 45
effectiveValue = 45
source = OFFICE

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the office resets its configuration and the tenant has 20:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;explicitValue = null
effectiveValue = 20
source = TENANT

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If neither scope defines an override:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;explicitValue = null
effectiveValue = 30
source = PLATFORM_DEFAULT

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The write path validates the candidate value, checks whether the requested scope is permitted, verifies authorization, persists the override, records the change, updates cache generations, and emits domain events where needed.&lt;/p&gt;

&lt;p&gt;The reset path goes through the same lifecycle but removes the override, restoring inheritance instead of persisting the current effective value.&lt;/p&gt;

&lt;p&gt;The read path resolves hierarchy without exposing that hierarchy to business services.&lt;/p&gt;

&lt;p&gt;The bulk read path performs the same resolution without generating hundreds of database calls.&lt;/p&gt;

&lt;p&gt;The catalog joins code-owned specifications with database-owned lifecycle and presentation metadata.&lt;/p&gt;

&lt;p&gt;That is no longer “a key and a value in a table.”&lt;/p&gt;

&lt;p&gt;It is a configuration domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should you build this?
&lt;/h2&gt;

&lt;p&gt;Probably later than you think.&lt;/p&gt;

&lt;p&gt;If your application has a few stable properties, keep explicit fields. They are easier to understand and harder to misuse.&lt;/p&gt;

&lt;p&gt;A dedicated settings subsystem starts making sense when several concerns appear together: runtime modification, ownership hierarchy, inheritance, overrides, explicit versus effective values, authorization differences, dynamic interfaces, frequent definition changes, validation metadata, auditing, or lifecycle requirements.&lt;/p&gt;

&lt;p&gt;The warning sign is not that you have many settings.&lt;/p&gt;

&lt;p&gt;It is that every new setting requires another special rule, and those rules are spreading across unrelated services.&lt;/p&gt;

&lt;p&gt;When that happens, configuration has developed behavior of its own.&lt;/p&gt;

&lt;p&gt;It needs a boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The hard part of a setting is rarely storing &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;30&lt;/code&gt;, or &lt;code&gt;"Africa/Casablanca"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The hard part is defining what that value means when it is absent, inherited, overridden, reset, validated, secured, cached, changed, or retired.&lt;/p&gt;

&lt;p&gt;That is the hidden cost behind:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Just add a setting.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once those rules exist, the setting is no longer just configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is part of your domain.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Published via &lt;a href="https://zyvop.com/the-hidden-cost-of-just-add-a-setting-1qrrq?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=syndication" rel="noopener noreferrer"&gt;ZyVOP&lt;/a&gt; — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium &amp;amp; Hashnode in 1 click.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>configuration</category>
      <category>domaindrivendesign</category>
      <category>backend</category>
      <category>java</category>
    </item>
    <item>
      <title>Stop Creating an Endpoint for Every Button</title>
      <dc:creator>Ismail ZAHIR</dc:creator>
      <pubDate>Mon, 07 Sep 2026 13:30:23 +0000</pubDate>
      <link>https://dev.to/ismailzahir/stop-creating-an-endpoint-for-every-button-3918</link>
      <guid>https://dev.to/ismailzahir/stop-creating-an-endpoint-for-every-button-3918</guid>
      <description>&lt;p&gt;There is a pattern that feels completely natural when building an API.&lt;/p&gt;

&lt;p&gt;The frontend gets an &lt;strong&gt;Approve&lt;/strong&gt; button, so the backend gets an endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /appointments/42/approve

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then more requirements arrive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /appointments/42/reject
POST /appointments/42/cancel
POST /appointments/42/archive

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each endpoint makes sense individually. The intent is explicit, authorization can be attached to individual operations, and OpenAPI documents exactly what the client can call.&lt;/p&gt;

&lt;p&gt;But as the workflow grows, so does the controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/{id}/approve"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/{id}/reject"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/{id}/cancel"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/{id}/archive"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/{id}/mark-missed"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When several of these methods eventually do little more than validate a transition and assign a different status, I start asking a different question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Am I exposing real domain operations, or am I turning every UI action into an HTTP endpoint?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That distinction matters more than whether a URL contains a verb.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the domain, not the button
&lt;/h2&gt;

&lt;p&gt;Imagine an appointment with these states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;AppointmentStatus&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;APPROVED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;REJECTED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;CANCELED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;MISSED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;ARCHIVED&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frontend might expose &lt;strong&gt;Approve&lt;/strong&gt;, &lt;strong&gt;Reject&lt;/strong&gt;, and &lt;strong&gt;Cancel&lt;/strong&gt; buttons, but those buttons are only one way of interacting with the workflow.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MISSED&lt;/code&gt;, for example, might not come from a button at all. A scheduled process could detect appointments whose time has passed and transition them automatically. Another client might expose the workflow through a menu, while an integration might have no UI at all.&lt;/p&gt;

&lt;p&gt;The domain still contains the same states and transition rules.&lt;/p&gt;

&lt;p&gt;That was the useful shift for me:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The UI triggers domain behavior, but it shouldn't define the domain model.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When status really is the resource being changed
&lt;/h2&gt;

&lt;p&gt;If several operations fundamentally mean "move this resource into another valid state," exposing the transition directly can make sense:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;PATCH /appointments/42/status

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"APPROVED"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are plenty of workflows where this model is natural:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DRAFT → ACTIVE
ACTIVE → INACTIVE

OPEN → CLOSED
CLOSED → ARCHIVED

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transition has no special payload and no independent business meaning beyond moving the resource through its lifecycle.&lt;/p&gt;

&lt;p&gt;Appointments can contain transitions like this too. The endpoint describes the requested state rather than mirroring whichever button happened to trigger it.&lt;/p&gt;

&lt;p&gt;But accepting a target status should not turn the application into unrestricted CRUD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.                ┌──→ REJECTED
                 │
PENDING ─────────┼──→ CANCELED
                 │
                 └──→ APPROVED
                         │
                         ├──→ CANCELED
                         ├──→ MISSED
                         └──→ ARCHIVED

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CANCELED → APPROVED&lt;/code&gt; might be forbidden. &lt;code&gt;APPROVED → MISSED&lt;/code&gt; might only become valid after the appointment time.&lt;/p&gt;

&lt;p&gt;Once those rules exist, &lt;code&gt;status&lt;/code&gt; is no longer just an enum field. It is part of a state machine.&lt;/p&gt;

&lt;p&gt;For rules that depend only on aggregate state, the model can remain simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;transitionTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AppointmentStatus&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;canTransitionTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidStatusTransitionException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a transition carries its own contextual invariant, a named domain method often becomes clearer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;markMissed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nc"&gt;AppointmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;APPROVED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidStatusTransitionException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;AppointmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MISSED&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scheduledAt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAfter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TransitionNotYetAllowedException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;AppointmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MISSED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;scheduledAt&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AppointmentStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MISSED&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application layer can obtain &lt;code&gt;now&lt;/code&gt; from an injected &lt;code&gt;Clock&lt;/code&gt;, perform authorization, and orchestrate persistence while the aggregate protects the transition invariant.&lt;/p&gt;

&lt;p&gt;That distinction between uniform transitions and operations with their own inputs or invariants will matter again in a moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't replace many endpoints with one god endpoint
&lt;/h2&gt;

&lt;p&gt;Once endpoint proliferation becomes visible, the opposite extreme is tempting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /appointments/42/action

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"APPROVE"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Soon it becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SEND_REMINDER"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"RESCHEDULE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-10T10:30:00"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;/action&lt;/code&gt; is an RPC dispatcher with an increasingly polymorphic request schema.&lt;/p&gt;

&lt;p&gt;Approve, cancel, reschedule, send a reminder, export, and generate a document do not become the same operation because they share an endpoint.&lt;/p&gt;

&lt;p&gt;Consolidation only helps when the operations actually share semantics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The strongest counterexample: different transitions need different data
&lt;/h2&gt;

&lt;p&gt;This is where a generic status endpoint starts becoming less attractive.&lt;/p&gt;

&lt;p&gt;Approval might require nothing more than:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"APPROVED"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But rejection might require a reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"REJECTED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Provider unavailable"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cancellation might require a reason and information related to the cancellation policy.&lt;/p&gt;

&lt;p&gt;Trying to force all of that through one request eventually produces something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CANCELED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refundPolicy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"comment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the schema contains fields that are optional syntactically but conditionally required semantically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if status == REJECTED → reason required
if status == CANCELED → reason required

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At that point, the generic endpoint may be hiding domain concepts rather than simplifying them.&lt;/p&gt;

&lt;p&gt;This gives me a useful heuristic:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;When a transition needs its own meaningful payload, it is often a domain command wearing a status change.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Cancellation might therefore deserve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /appointments/42/cancel

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with its own contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Schedule conflict"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Internally, that doesn't have to mean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;appointment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;CANCELED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The domain can expose the operation explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;appointment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cancel&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;while simpler lifecycle transitions can still use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;appointment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transitionTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the distinction I find useful: &lt;strong&gt;uniform lifecycle transitions can share a transition abstraction; operations with their own inputs or invariants can become named domain behavior.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And this is also why I wouldn't invent a &lt;code&gt;RESCHEDULED&lt;/code&gt; status just to fit rescheduling through the same endpoint. Rescheduling changes the appointment's schedule. It is behavior, not necessarily another lifecycle state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some status changes are really outcomes
&lt;/h2&gt;

&lt;p&gt;Consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /invoices/42/send

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sending an invoice might generate a document, create an immutable snapshot, contact an external mail provider, record a delivery attempt, and publish an event.&lt;/p&gt;

&lt;p&gt;Reducing all of that to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;PATCH /invoices/42/status

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SENT"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;misrepresents what the client is asking the system to do.&lt;/p&gt;

&lt;p&gt;The request isn't "make this field equal &lt;code&gt;SENT&lt;/code&gt;." It is &lt;strong&gt;send this invoice&lt;/strong&gt;. &lt;code&gt;SENT&lt;/code&gt; is an outcome of the operation.&lt;/p&gt;

&lt;p&gt;The same reasoning applies to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /orders/{id}/refund
POST /reports/{id}/generate
POST /users/{id}/reset-password

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trying to eliminate verbs simply for REST purity can make an API less expressive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Valid doesn't mean authorized
&lt;/h2&gt;

&lt;p&gt;There is another dimension that shouldn't be hidden inside the state machine.&lt;/p&gt;

&lt;p&gt;Suppose this transition is valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APPROVED → MISSED

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That doesn't mean every authenticated user is allowed to perform it.&lt;/p&gt;

&lt;p&gt;A provider might be allowed to mark an appointment as missed. The patient probably shouldn't be able to mark their own appointment as missed. A scheduled system process might also be authorized to perform the same transition.&lt;/p&gt;

&lt;p&gt;So I treat these as separate questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Is APPROVED → MISSED a valid domain transition?

                 ≠

Is this actor allowed to perform it?

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The aggregate can protect its lifecycle invariants, while the application or authorization layer determines whether the current actor is permitted to request the operation.&lt;/p&gt;

&lt;p&gt;A transition can therefore be valid but unauthorized.&lt;/p&gt;

&lt;p&gt;This also closes one apparent advantage of action endpoints from the introduction. Having &lt;code&gt;/approve&lt;/code&gt; and &lt;code&gt;/cancel&lt;/code&gt; gives you convenient places to attach different authorization rules, but the URL shape itself doesn't solve authorization. A generic transition endpoint can still authorize based on the actor, the current resource, and the requested transition.&lt;/p&gt;

&lt;p&gt;That distinction becomes particularly important when the API tells clients which operations are currently available.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does the frontend know what's allowed?
&lt;/h2&gt;

&lt;p&gt;Explicit action endpoints have an advantage: discoverability.&lt;/p&gt;

&lt;p&gt;If OpenAPI exposes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /approve
POST /reject
POST /cancel

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the operations are visible at design time.&lt;/p&gt;

&lt;p&gt;With:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;PATCH /status

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the schema might tell the client which status values exist without telling it which transitions are valid &lt;strong&gt;from the current state for the current caller&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The naive solution is to reproduce the workflow in Angular:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appointment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PENDING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// show approve/reject/cancel&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But now the frontend contains another copy of business rules that already exist on the backend.&lt;/p&gt;

&lt;p&gt;For simple workflows, the API can expose permitted transitions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PENDING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allowedTransitions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"APPROVED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"REJECTED"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that &lt;code&gt;CANCELED&lt;/code&gt; is absent here even though it is structurally valid from &lt;code&gt;PENDING&lt;/code&gt;. That could be intentional: this representation is for the &lt;strong&gt;current caller&lt;/strong&gt;, not merely a dump of every transition in the state machine.&lt;/p&gt;

&lt;p&gt;For richer workflows, operation descriptors can span both state transitions and commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PENDING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"approve"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PATCH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"href"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/appointments/42/status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"APPROVED"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cancel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"href"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/appointments/42/cancel"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact representation isn't the important part. The principle is: &lt;strong&gt;the backend should remain authoritative about what the current caller can do.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The frontend can use that information to render controls without reproducing the entire state machine. And none of this replaces server-side authorization; clients can construct arbitrary requests, so the server still validates every operation.&lt;/p&gt;

&lt;p&gt;At this point, the modeling question is mostly settled. The remaining question is whether the chosen design behaves correctly when requests fail, overlap, or get retried.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure semantics matter too
&lt;/h2&gt;

&lt;p&gt;A generic endpoint doesn't require generic errors.&lt;/p&gt;

&lt;p&gt;These are three different failures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CANCELED → APPROVED
The transition itself is not allowed.

PENDING → APPROVED by this actor
The transition is valid, but the actor isn't authorized.

PENDING → APPROVED against an old resource version
The request was valid, but the resource changed first.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They should remain distinguishable to the client.&lt;/p&gt;

&lt;p&gt;An authorization failure naturally maps to &lt;code&gt;403 Forbidden&lt;/code&gt;. An invalid domain transition can be represented as a domain conflict such as &lt;code&gt;409 Conflict&lt;/code&gt;, or &lt;code&gt;422 Unprocessable Content&lt;/code&gt; if that convention better matches the API. A failed &lt;code&gt;If-Match&lt;/code&gt; precondition has the more specific &lt;code&gt;412 Precondition Failed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The exact status-code policy should be consistent across the API. What matters here is that consolidating several state changes behind one endpoint doesn't mean collapsing their failure semantics into a generic &lt;code&gt;"status update failed"&lt;/code&gt; response.&lt;/p&gt;

&lt;h2&gt;
  
  
  State transitions have a concurrency problem
&lt;/h2&gt;

&lt;p&gt;Consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Appointment&lt;/span&gt; &lt;span class="n"&gt;appointment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;appointment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transitionTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appointment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now two requests arrive almost simultaneously. One coordinator approves the appointment while another cancels it.&lt;/p&gt;

&lt;p&gt;Both load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status = PENDING

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both transitions are individually valid, so both pass validation. Without concurrency control, the last write can silently overwrite the first.&lt;/p&gt;

&lt;p&gt;For JPA applications, optimistic locking is one common protection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Version&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the HTTP layer, an ETag combined with &lt;code&gt;If-Match&lt;/code&gt; can express the same expectation while keeping the precondition in HTTP metadata.&lt;/p&gt;

&lt;p&gt;For state-machine APIs, another option is to make the expected state explicit in the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PENDING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"APPROVED"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conceptually, that is a domain-level compare-and-swap:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Change this to &lt;code&gt;APPROVED&lt;/code&gt;, but only if it is still &lt;code&gt;PENDING&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The trade-off is partly about layering. &lt;code&gt;If-Match&lt;/code&gt; keeps the precondition at the transport level, but usually works with a version or opaque ETag. A &lt;code&gt;from&lt;/code&gt; field expresses the expected domain state directly, but puts that precondition into the request body.&lt;/p&gt;

&lt;p&gt;They also detect different things: an ETag or version can detect any relevant resource modification, while &lt;code&gt;from&lt;/code&gt; only expresses an expectation about the current workflow state.&lt;/p&gt;

&lt;p&gt;If a conflict is detected, blindly retrying inside the service is dangerous. The resource should be re-read and the operation reconsidered against its new state.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Validating a transition isn't enough if the state you validated is no longer current when you commit it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Commands have the mirror-image problem: retries
&lt;/h2&gt;

&lt;p&gt;State transitions force us to think about &lt;strong&gt;concurrent updates&lt;/strong&gt;. Commands with external side effects force us to think about &lt;strong&gt;duplicate execution&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /invoices/42/send

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server successfully sends the email, but the client times out before receiving the response and retries. Without protection, the customer may receive the invoice twice.&lt;/p&gt;

&lt;p&gt;Depending on the operation, idempotency might involve an idempotency key, a persisted command identifier, or checking whether the operation has already completed.&lt;/p&gt;

&lt;p&gt;So the two sides of the design have related correctness concerns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;State transition → Is the state I am changing still current?

Domain command  → Have I already executed this request?

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choosing the right HTTP shape doesn't solve either problem. It makes the semantics clearer so they can be handled deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remove the UI and ask again
&lt;/h2&gt;

&lt;p&gt;When I'm unsure about an endpoint, I find it useful to mentally remove the frontend.&lt;/p&gt;

&lt;p&gt;Operations such as &lt;strong&gt;generate report&lt;/strong&gt;, &lt;strong&gt;refund order&lt;/strong&gt;, and &lt;strong&gt;send invoice&lt;/strong&gt; clearly still exist without a button.&lt;/p&gt;

&lt;p&gt;Now consider &lt;strong&gt;Archive&lt;/strong&gt;. Is archiving a meaningful business operation with its own inputs, invariants, and side effects? Or is &lt;code&gt;ARCHIVED&lt;/code&gt; simply another lifecycle state?&lt;/p&gt;

&lt;p&gt;There is no universal answer, and that's the point.&lt;/p&gt;

&lt;p&gt;The API shouldn't acquire &lt;code&gt;/archive&lt;/code&gt; merely because somebody added an Archive button. The operation should exist because the domain gives "archive" that meaning.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MISSED&lt;/code&gt; makes the distinction particularly clear. If a scheduled job can move an appointment into that state, the workflow exists independently of any button that might also expose it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I use now
&lt;/h2&gt;

&lt;p&gt;When a frontend requirement arrives, I try not to start with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What endpoint does this button need?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I start with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What happened in the domain?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the answer is simply:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This resource moved from one valid lifecycle state to another.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;then a state-oriented API such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;PATCH /appointments/{id}/status

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;may be the clearest representation.&lt;/p&gt;

&lt;p&gt;If the answer is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The system performed a business operation with its own inputs, invariants, or side effects.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;then an explicit command may be the better abstraction.&lt;/p&gt;

&lt;p&gt;That doesn't mean verbs in URLs are bad. It doesn't mean every status deserves &lt;code&gt;PATCH&lt;/code&gt;. And it doesn't mean the HTTP contract has to mirror the internal domain API method for method.&lt;/p&gt;

&lt;p&gt;The rule I ended up with is simpler:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't create an endpoint because a button exists. Create it because the domain operation exists.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Buttons change. Clients change. Some transitions eventually happen without a user at all.&lt;/p&gt;

&lt;p&gt;The domain is the more stable boundary.&lt;/p&gt;

&lt;p&gt;That's the boundary I want the API to represent.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://zyvop.com/stop-creating-an-endpoint-for-every-button-7lfue?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=syndication" rel="noopener noreferrer"&gt;ZyVOP&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;💡 For more articles like this, &lt;a href="https://zyvop.com/newsletter?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=syndication-footer" rel="noopener noreferrer"&gt;subscribe to the ZyVOP newsletter&lt;/a&gt;!&lt;/p&gt;

</description>
      <category>apidesign</category>
      <category>rest</category>
      <category>domaindrivendesign</category>
      <category>backend</category>
    </item>
    <item>
      <title>My Pull Request Failed — Because GitHub Actions Was Protecting the Repository</title>
      <dc:creator>Ismail ZAHIR</dc:creator>
      <pubDate>Fri, 04 Sep 2026 13:47:07 +0000</pubDate>
      <link>https://dev.to/ismailzahir/my-pull-request-failed-because-github-actions-was-protecting-the-repository-2h8i</link>
      <guid>https://dev.to/ismailzahir/my-pull-request-failed-because-github-actions-was-protecting-the-repository-2h8i</guid>
      <description>&lt;p&gt;Sometimes the most useful security lessons don't start with a security audit.&lt;/p&gt;

&lt;p&gt;They start with a failed CI job.&lt;/p&gt;

&lt;p&gt;Recently, while contributing to an open-source project, I opened a pull request and expected the usual sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;checkout → install → build → test

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, the workflow stopped at &lt;code&gt;actions/checkout&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The error was surprisingly explicit:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Refusing to check out fork pull request code from a &lt;code&gt;pull_request_target&lt;/code&gt; workflow.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At first, this looked like a CI configuration problem.&lt;/p&gt;

&lt;p&gt;Maybe the checkout action needed another option. Maybe something had changed in a newer version.&lt;/p&gt;

&lt;p&gt;There was even an escape hatch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;allow-unsafe-pr-checkout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding it would have been easy.&lt;/p&gt;

&lt;p&gt;But the name alone should make you stop before doing that.&lt;/p&gt;

&lt;p&gt;Why was checking out my pull request considered &lt;strong&gt;unsafe&lt;/strong&gt;?&lt;/p&gt;

&lt;p&gt;That question led me into an important GitHub Actions security boundary that is easy to miss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request_target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;look similar.&lt;/p&gt;

&lt;p&gt;They are not.&lt;/p&gt;

&lt;p&gt;And choosing the wrong one can turn a normal CI workflow into a path for executing untrusted code with repository privileges.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow looked perfectly normal
&lt;/h2&gt;

&lt;p&gt;The project had a quality-check workflow for pull requests.&lt;/p&gt;

&lt;p&gt;Simplified, its intent was something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Quality Check&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request_target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src/**"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/**"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;package.json"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pnpm-lock.yaml"&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;

      &lt;span class="c1"&gt;# install dependencies&lt;/span&gt;
      &lt;span class="c1"&gt;# build&lt;/span&gt;
      &lt;span class="c1"&gt;# lint&lt;/span&gt;
      &lt;span class="c1"&gt;# test&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing here immediately looks dangerous.&lt;/p&gt;

&lt;p&gt;It's a pull request.&lt;/p&gt;

&lt;p&gt;We want to test the pull request.&lt;/p&gt;

&lt;p&gt;So we check out its code and execute the project's quality checks.&lt;/p&gt;

&lt;p&gt;But there's a more important question than:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What commands does this workflow execute?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Whose code are we executing, and what privileges does it have while running?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That changes the entire security model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two events with very different trust models
&lt;/h2&gt;

&lt;p&gt;GitHub provides both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;pull_request&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;pull_request_target&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They both respond to pull request activity, but they exist for different purposes.&lt;/p&gt;

&lt;p&gt;Understanding that difference requires thinking about &lt;strong&gt;trusted and untrusted code&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;pull_request&lt;/code&gt;: run CI against the proposed change
&lt;/h3&gt;

&lt;p&gt;For a normal CI workflow, we might write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Quality Check&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the natural environment for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;build
lint
test
type-check
static analysis

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because the workflow is supposed to process the code proposed by the contributor.&lt;/p&gt;

&lt;p&gt;For pull requests coming from forks, GitHub applies restrictions to protect the target repository.&lt;/p&gt;

&lt;p&gt;The important mental model is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        Contributor's PR
               │
               ▼
        pull_request
               │
               ▼
      Restricted context
               │
               ▼
        Checkout PR code
               │
               ▼
      Build / lint / test

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code is untrusted.&lt;/p&gt;

&lt;p&gt;And the environment is designed accordingly.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;pull_request_target&lt;/code&gt; solves a different problem
&lt;/h3&gt;

&lt;p&gt;Now consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request_target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The word &lt;code&gt;target&lt;/code&gt; is important.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;pull_request_target&lt;/code&gt; workflow executes using the context of the &lt;strong&gt;base repository&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Its workflow definition comes from the trusted base branch rather than from the contributor's pull request.&lt;/p&gt;

&lt;p&gt;That makes it useful for operations that need to interact with the repository while responding to an external pull request.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apply labels
comment on a PR
triage contributions
manage PR metadata
perform privileged repository automation

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        Contributor's PR
               │
               ▼
    pull_request_target
               │
               ▼
      Trusted base context
               │
               ▼
      Manage the pull request

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's missing.&lt;/p&gt;

&lt;p&gt;We aren't executing the contributor's application code.&lt;/p&gt;

&lt;p&gt;That's intentional.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem starts when those worlds are mixed
&lt;/h2&gt;

&lt;p&gt;Imagine this workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PR Check&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request_target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.head.sha }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We've now changed the architecture.&lt;/p&gt;

&lt;p&gt;The workflow runs in a trusted context.&lt;/p&gt;

&lt;p&gt;Then we explicitly fetch code controlled by the pull request author.&lt;/p&gt;

&lt;p&gt;Then we execute it.&lt;/p&gt;

&lt;p&gt;The trust boundary becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌───────────────────────────────┐
│       UNTRUSTED SOURCE        │
│                               │
│   Pull request from a fork    │
└──────────────┬────────────────┘
               │
               │ checkout PR code
               ▼
┌───────────────────────────────┐
│        TRUSTED CONTEXT        │
│                               │
│      pull_request_target      │
│                               │
│  GITHUB_TOKEN                 │
│  repository secrets           │
│  cache scope                  │
│  runner access                │
└──────────────┬────────────────┘
               │
               │ npm ci
               │ npm test
               ▼
        Untrusted code runs
        inside trusted context

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the dangerous combination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repository code is executable input
&lt;/h2&gt;

&lt;p&gt;This is the part that's easy to underestimate.&lt;/p&gt;

&lt;p&gt;You might look at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and think:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I'm only installing dependencies.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But dependency installation can execute lifecycle scripts.&lt;/p&gt;

&lt;p&gt;Or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and think:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I'm only running tests.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But who controls the tests?&lt;/p&gt;

&lt;p&gt;The pull request does.&lt;/p&gt;

&lt;p&gt;The same applies to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run build
pnpm &lt;span class="nb"&gt;install
&lt;/span&gt;make
./scripts/check.sh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A contributor may be able to modify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;package.json
build scripts
test files
Makefiles
configuration
dependencies
shell scripts

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So when a CI job checks out a pull request, the repository itself needs to be treated as potentially executable input.&lt;/p&gt;

&lt;p&gt;That gives us the dangerous equation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UNTRUSTED CODE
      +
PRIVILEGED WORKFLOW
      =
SECURITY BOUNDARY VIOLATION

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  This is known as a "pwn request"
&lt;/h2&gt;

&lt;p&gt;GitHub Security Lab describes this class of vulnerability as a &lt;strong&gt;pwn request&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The problem isn't that &lt;code&gt;pull_request_target&lt;/code&gt; itself is insecure.&lt;/p&gt;

&lt;p&gt;That's an important distinction.&lt;/p&gt;

&lt;p&gt;The dangerous pattern is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pull_request_target
        +
checkout untrusted PR
        +
execute that PR

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pull_request_target&lt;/code&gt; has legitimate uses.&lt;/p&gt;

&lt;p&gt;The vulnerability appears when the trusted and untrusted execution models are combined incorrectly.&lt;/p&gt;

&lt;h2&gt;
  
  
  And now &lt;code&gt;actions/checkout&lt;/code&gt; actively protects against it
&lt;/h2&gt;

&lt;p&gt;This is what made my failed CI job particularly interesting.&lt;/p&gt;

&lt;p&gt;Recent versions of &lt;code&gt;actions/checkout&lt;/code&gt; include protection against this exact pattern.&lt;/p&gt;

&lt;p&gt;When a workflow running under a privileged event such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;pull_request_target&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;tries to check out code from an external fork, &lt;code&gt;actions/checkout&lt;/code&gt; can refuse the operation.&lt;/p&gt;

&lt;p&gt;That's why I saw the error.&lt;/p&gt;

&lt;p&gt;The action even provides an explicit opt-out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;allow-unsafe-pr-checkout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But look carefully at that property name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;allow-unsafe-pr-checkout

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;allow-fork-checkout

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;enable-pr-checkout

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub is deliberately making the security implication visible.&lt;/p&gt;

&lt;p&gt;The option exists for cases where someone has carefully evaluated the trust boundary and genuinely needs the behavior.&lt;/p&gt;

&lt;p&gt;It should not be the default fix for a failing CI pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  My first question became: why does this workflow need &lt;code&gt;pull_request_target&lt;/code&gt;?
&lt;/h2&gt;

&lt;p&gt;The workflow wasn't publishing a package.&lt;/p&gt;

&lt;p&gt;It wasn't deploying anything.&lt;/p&gt;

&lt;p&gt;It wasn't modifying repository contents.&lt;/p&gt;

&lt;p&gt;It wasn't performing privileged PR management.&lt;/p&gt;

&lt;p&gt;It was running quality checks.&lt;/p&gt;

&lt;p&gt;In other words:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;checkout
   ↓
install
   ↓
build
   ↓
lint
   ↓
test

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's exactly what &lt;code&gt;pull_request&lt;/code&gt; is designed for.&lt;/p&gt;

&lt;p&gt;So instead of bypassing the protection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;allow-unsafe-pr-checkout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the fix was to change the trust model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Before
&lt;/h3&gt;

&lt;p&gt;The relevant part looked conceptually like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Quality Check&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;

  &lt;span class="na"&gt;pull_request_target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src/**"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/**"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;package.json"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pnpm-lock.yaml"&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;

      &lt;span class="c1"&gt;# install&lt;/span&gt;
      &lt;span class="c1"&gt;# lint&lt;/span&gt;
      &lt;span class="c1"&gt;# test&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a workflow whose purpose is to execute the proposed changes, &lt;code&gt;pull_request_target&lt;/code&gt; introduces a privileged context that isn't required.&lt;/p&gt;

&lt;h3&gt;
  
  
  After
&lt;/h3&gt;

&lt;p&gt;The change is almost boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Quality Check&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;

  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src/**"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/**"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;package.json"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pnpm-lock.yaml"&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;

      &lt;span class="c1"&gt;# install&lt;/span&gt;
      &lt;span class="c1"&gt;# lint&lt;/span&gt;
      &lt;span class="c1"&gt;# test&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important change is just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- pull_request_target:
&lt;/span&gt;&lt;span class="gi"&gt;+ pull_request:
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And I would also make the permissions required by the CI job explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The YAML change is tiny.&lt;/p&gt;

&lt;p&gt;The architectural change isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after: the actual security model
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The security model before
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    Fork PR
                       │
                       ▼
              pull_request_target
                       │
             trusted base context
                       │
                       ▼
                checkout PR
                       │
                       ▼
                execute code
                       │
                       ▼
              ⚠ trust boundary
                 has been crossed

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The security model after
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    Fork PR
                       │
                       ▼
                 pull_request
                       │
               restricted context
                       │
                       ▼
                checkout PR
                       │
                       ▼
              build / lint / test
                       │
                       ▼
                 CI result

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the execution model matches the purpose of the workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson isn't "&lt;code&gt;pull_request_target&lt;/code&gt; is bad"
&lt;/h2&gt;

&lt;p&gt;That would be the wrong conclusion.&lt;/p&gt;

&lt;p&gt;A better rule is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Choose the event based on the trust level required by the job.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Consider two workflows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workflow A
&lt;/h3&gt;

&lt;p&gt;It needs to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;checkout contributor code
install dependencies
compile
run tests
run linting

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's untrusted-code execution.&lt;/p&gt;

&lt;p&gt;Use a low-privilege context such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;pull_request&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Workflow B
&lt;/h3&gt;

&lt;p&gt;It needs to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;label the PR
comment on it
perform triage
update repository metadata

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It may need a trusted repository context.&lt;/p&gt;

&lt;p&gt;That's where:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;pull_request_target&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;can make sense.&lt;/p&gt;

&lt;p&gt;But don't then casually checkout and execute the contributor's code.&lt;/p&gt;

&lt;h2&gt;
  
  
  A mental model I now use
&lt;/h2&gt;

&lt;p&gt;When reviewing a GitHub Actions workflow, I ask two questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Question 1: Does this job execute contributor-controlled code?
&lt;/h3&gt;

&lt;p&gt;That includes obvious commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./script-from-the-repository.sh&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;but also less obvious ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If yes, I treat the job as executing untrusted code.&lt;/p&gt;

&lt;p&gt;Then I ask:&lt;/p&gt;

&lt;h3&gt;
  
  
  Question 2: Does this job have privileged access?
&lt;/h3&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repository write permissions
secrets
publishing credentials
deployment credentials
privileged caches
internal infrastructure
self-hosted runners

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the answer to both questions is yes, the workflow deserves immediate attention.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Does the job execute PR code?
             │
      ┌──────┴──────┐
      NO           YES
      │             │
      ▼             ▼
  Lower risk   Does it hold privileges?
                     │
              ┌──────┴──────┐
              NO           YES
              │             │
              ▼             ▼
          Lower risk    ⚠ REVIEW THIS

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The goal is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Don't combine untrusted code execution with unnecessary privileges.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Separate CI from privileged automation
&lt;/h2&gt;

&lt;p&gt;Suppose a project genuinely needs both.&lt;/p&gt;

&lt;p&gt;It wants to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  build and test external contributions;&lt;/li&gt;
&lt;li&gt;  perform privileged actions after those checks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't automatically put everything into one privileged workflow.&lt;/p&gt;

&lt;p&gt;Separate responsibilities.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workflow 1: untrusted CI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PR CI&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its responsibility is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Determine whether the proposed code works.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nothing more.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workflow 2: trusted automation
&lt;/h3&gt;

&lt;p&gt;A separate trusted workflow can perform operations that genuinely require additional permissions.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PR Metadata&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request_target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# Work with PR metadata.&lt;/span&gt;
      &lt;span class="c1"&gt;# Do not execute contributor-controlled code.&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its responsibility is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Manage the pull request.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Execute the pull request.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That distinction dramatically simplifies the security model.&lt;/p&gt;

&lt;p&gt;For more complex cases where privileged work must happen after untrusted CI, GitHub also documents patterns using separate workflows such as &lt;code&gt;workflow_run&lt;/code&gt;. But artifacts crossing from an untrusted workflow into a privileged one still need to be treated as untrusted data.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;permissions&lt;/code&gt; is part of the architecture too
&lt;/h2&gt;

&lt;p&gt;Changing the event is only part of hardening a workflow.&lt;/p&gt;

&lt;p&gt;GitHub Actions provides a &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;, and its permissions should follow the principle of least privilege.&lt;/p&gt;

&lt;p&gt;A CI workflow often needs little more than:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A PR-management workflow might legitimately need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A release workflow might need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important question is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What permissions might this workflow eventually need?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is the minimum permission this job needs to perform its responsibility?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A linter doesn't need to create releases.&lt;/p&gt;

&lt;p&gt;A test suite doesn't need deployment credentials.&lt;/p&gt;

&lt;p&gt;A build shouldn't receive package-publishing credentials just because another job publishes packages.&lt;/p&gt;

&lt;p&gt;Permissions should follow responsibilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets aren't the only thing worth protecting
&lt;/h2&gt;

&lt;p&gt;It's tempting to think:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We don't use any secrets, so executing the PR here is fine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's incomplete.&lt;/p&gt;

&lt;p&gt;The security boundary can include more than explicit secrets.&lt;/p&gt;

&lt;p&gt;Depending on the workflow, there may also be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GITHUB_TOKEN permissions
repository access
cache state
artifacts
package credentials
deployment environments
runner infrastructure

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And self-hosted runners deserve particular attention.&lt;/p&gt;

&lt;p&gt;If arbitrary external code runs on infrastructure connected to private systems, the threat model is very different from an isolated disposable GitHub-hosted runner.&lt;/p&gt;

&lt;p&gt;So the right question isn't just:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can this pull request read &lt;code&gt;MY_SECRET&lt;/code&gt;?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What can the environment access or modify while contributor-controlled code is running?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  CI configuration is security architecture
&lt;/h2&gt;

&lt;p&gt;One thing changed for me after investigating this issue.&lt;/p&gt;

&lt;p&gt;I no longer see this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request_target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;as merely a CI trigger.&lt;/p&gt;

&lt;p&gt;And I don't see this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;as merely configuration.&lt;/p&gt;

&lt;p&gt;Or this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.head.sha }}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;as merely checkout behavior.&lt;/p&gt;

&lt;p&gt;Together, these settings answer fundamental security questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Who controls the code?

Which version of the workflow runs?

Which credentials are available?

What can the job modify?

Which infrastructure can it access?

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a security model.&lt;/p&gt;

&lt;p&gt;Just written in YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the failed pipeline was useful
&lt;/h2&gt;

&lt;p&gt;The easiest response to my original failure would have been:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;allow-unsafe-pr-checkout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CI probably would have moved past the checkout step.&lt;/p&gt;

&lt;p&gt;But the security warning wasn't the problem.&lt;/p&gt;

&lt;p&gt;The workflow architecture was.&lt;/p&gt;

&lt;p&gt;The better question was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why is a quality-check workflow asking to execute untrusted fork code inside a privileged context?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once phrased that way, the solution became obvious.&lt;/p&gt;

&lt;p&gt;It didn't need that context.&lt;/p&gt;

&lt;p&gt;So I changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;pull_request_target&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;pull_request&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and let the CI execute the proposed code in the security context intended for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final takeaway
&lt;/h2&gt;

&lt;p&gt;The one-line fix wasn't the interesting part.&lt;/p&gt;

&lt;p&gt;This was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- pull_request_target:
&lt;/span&gt;&lt;span class="gi"&gt;+ pull_request:
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part was understanding &lt;strong&gt;why&lt;/strong&gt; that line matters.&lt;/p&gt;

&lt;p&gt;My rule now is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Never execute untrusted pull request code with privileges it doesn't need.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Use &lt;code&gt;pull_request&lt;/code&gt; when the job exists to build, lint, analyze, or test contributor code.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;pull_request_target&lt;/code&gt; when you genuinely need the trusted base-repository context for PR automation — and keep contributor-controlled code out of that execution path.&lt;/p&gt;

&lt;p&gt;Define explicit permissions.&lt;/p&gt;

&lt;p&gt;Separate untrusted CI from privileged automation.&lt;/p&gt;

&lt;p&gt;And when a security mechanism blocks something in your pipeline, don't immediately search for the flag that disables it.&lt;/p&gt;

&lt;p&gt;First ask why the protection exists.&lt;/p&gt;

&lt;p&gt;In my case, a failed checkout wasn't GitHub Actions getting in the way.&lt;/p&gt;

&lt;p&gt;It was GitHub Actions pointing at a security boundary I hadn't paid enough attention to.&lt;/p&gt;

&lt;p&gt;And that made the failed CI job more useful than a successful one would have been.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  GitHub Docs — &lt;a href="https://docs.github.com/en/actions/reference/security/secure-use" rel="noopener noreferrer"&gt;Secure use reference&lt;/a&gt;, including guidance for mitigating untrusted code checkout in privileged workflows.&lt;/li&gt;
&lt;li&gt;  GitHub Docs — &lt;a href="https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-github-actions-securely" rel="noopener noreferrer"&gt;Securely using pull_request_target&lt;/a&gt;, covering its trust model, fork checkout risks, hardening, and the &lt;code&gt;allow-unsafe-pr-checkout&lt;/code&gt; protection.&lt;/li&gt;
&lt;li&gt;  GitHub Security Lab — &lt;a href="https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/" rel="noopener noreferrer"&gt;Keeping your GitHub Actions and workflows secure: Preventing pwn requests&lt;/a&gt;, with examples of how privileged PR workflows can become vulnerable.&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://github.com/actions/checkout" rel="noopener noreferrer"&gt;actions/checkout&lt;/a&gt; — current documentation for the built-in protection against unsafe fork PR checkout.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://zyvop.com/my-pull-request-failed-because-github-actions-was-protecting-the-repository-vybui" rel="noopener noreferrer"&gt;ZyVOP&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;💡 For more articles like this, &lt;a href="https://zyvop.com/newsletter" rel="noopener noreferrer"&gt;subscribe to the ZyVOP newsletter&lt;/a&gt;!&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>cicd</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Built an Angular Authentication Layer for the Signals Era</title>
      <dc:creator>Ismail ZAHIR</dc:creator>
      <pubDate>Mon, 31 Aug 2026 18:03:08 +0000</pubDate>
      <link>https://dev.to/ismailzahir/i-built-an-angular-authentication-layer-for-the-signals-era-2p4b</link>
      <guid>https://dev.to/ismailzahir/i-built-an-angular-authentication-layer-for-the-signals-era-2p4b</guid>
      <description>&lt;p&gt;Authentication is one of those concerns that quickly spreads across an Angular application.&lt;/p&gt;

&lt;p&gt;A component needs to know whether the user is authenticated. A route needs authorization rules. HTTP requests need access tokens. The application needs to react when a session expires. Tests need to simulate authenticated and unauthenticated users.&lt;/p&gt;

&lt;p&gt;And once an identity provider such as Keycloak is introduced, it becomes very easy for provider-specific concepts to leak into the rest of the application.&lt;/p&gt;

&lt;p&gt;I wanted a different approach.&lt;/p&gt;

&lt;p&gt;So I built &lt;code&gt;ngx-auth-client&lt;/code&gt;, an Angular authentication library designed around Angular signals, a provider-agnostic authentication layer, functional route guards, in-memory token handling, and testability.&lt;/p&gt;

&lt;p&gt;The goal wasn't to create another wrapper around an identity provider.&lt;/p&gt;

&lt;p&gt;The goal was to create an &lt;strong&gt;authentication layer that belongs to the Angular application rather than to a particular authentication provider&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The application should depend on authentication capabilities, not on the identity provider implementing them.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you want to follow along with the implementation, you can install the library with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @ismailza/ngx-auth-client keycloak-js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then import the pieces you need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;provideAuth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;authGuard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;authTokenInterceptor&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@ismailza/ngx-auth-client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;withKeycloak&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@ismailza/ngx-auth-client/keycloak&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;withFakeAuth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@ismailza/ngx-auth-client/testing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is an important detail in that installation command.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;keycloak-js&lt;/code&gt; is an &lt;strong&gt;optional peer dependency&lt;/strong&gt;. The core package has no identity-provider dependency. Only the &lt;code&gt;/keycloak&lt;/code&gt; entry point requires it.&lt;/p&gt;

&lt;p&gt;That distinction is not just a packaging detail. It is part of the architecture.&lt;/p&gt;

&lt;p&gt;This is the story of the decisions behind the project.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Why build another authentication library?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Angular applications already have many ways to implement authentication.&lt;/p&gt;

&lt;p&gt;You can integrate an identity provider directly, use an SDK, write your own authentication service, or build a small abstraction around an existing provider.&lt;/p&gt;

&lt;p&gt;I've used the direct integration approach before.&lt;/p&gt;

&lt;p&gt;It works.&lt;/p&gt;

&lt;p&gt;Until authentication starts appearing everywhere.&lt;/p&gt;

&lt;p&gt;You might end up with code such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keycloak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;keycloak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;keycloak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem isn't that these APIs are bad.&lt;/p&gt;

&lt;p&gt;The problem is that the application starts knowing &lt;strong&gt;which authentication provider it is using&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Your components, route guards, interceptors, and services become coupled to Keycloak.&lt;/p&gt;

&lt;p&gt;I wanted to move that dependency to the edge of the architecture.&lt;/p&gt;

&lt;p&gt;Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Angular Application
        ↓
     Keycloak
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wanted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Angular Application
        ↓
 Authentication Layer
        ↓
 Authentication Provider
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That distinction became the foundation of &lt;code&gt;ngx-auth-client&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Authentication state should be Angular state&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;One of the first decisions I made was to make authentication state reactive using Angular signals.&lt;/p&gt;

&lt;p&gt;Instead of exposing authentication state through imperative getters, the library exposes it as reactive state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A component can consume that state directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`
    @if (auth.authenticated()) {
      &amp;lt;p&amp;gt;Welcome back!&amp;lt;/p&amp;gt;
    } @else {
      &amp;lt;p&amp;gt;Please sign in.&amp;lt;/p&amp;gt;
    }
  `&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HomeComponent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AuthService&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part isn't the syntax.&lt;/p&gt;

&lt;p&gt;It's that authentication becomes &lt;strong&gt;normal reactive application state&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When authentication changes, Angular can react naturally. A component doesn't need to subscribe to provider-specific authentication events just to keep its UI synchronized.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;One service for application code&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;AuthService&lt;/code&gt; is the single thing application code needs to inject.&lt;/p&gt;

&lt;p&gt;It adds several application-facing capabilities on top of the underlying provider port:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;initialization state with &lt;code&gt;ready()&lt;/code&gt;, &lt;code&gt;initError()&lt;/code&gt;, and &lt;code&gt;whenReady()&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;capability detection such as &lt;code&gt;canRegister()&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;role predicates such as &lt;code&gt;hasRole()&lt;/code&gt;, &lt;code&gt;hasAnyRole()&lt;/code&gt;, and &lt;code&gt;hasAllRoles()&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The underlying provider contract remains deliberately small.&lt;/p&gt;

&lt;p&gt;Setting everything up is a single provider call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;appConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ApplicationConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;provideAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nf"&gt;withKeycloak&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;realm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;clientId&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;

    &lt;span class="nf"&gt;provideHttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nf"&gt;withInterceptors&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;authTokenInterceptor&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;

    &lt;span class="nf"&gt;provideRouter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;strong&gt;Bootstrap doesn't have to wait for authentication&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;One subtle design decision is how initialization works.&lt;/p&gt;

&lt;p&gt;The application doesn't block bootstrap while the authentication session is restored.&lt;/p&gt;

&lt;p&gt;The adapter starts initialization as soon as the injector is created. &lt;code&gt;whenReady()&lt;/code&gt; resolves when initialization settles, and the route guard waits for that before making an authorization decision.&lt;/p&gt;

&lt;p&gt;That means unprotected application UI can render immediately while protected routes still wait for authentication state to become reliable.&lt;/p&gt;

&lt;p&gt;This avoids a common race condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application starts
       ↓
Authentication restoration starts
       ↓
Protected route evaluates too early
       ↓
User appears unauthenticated
       ↓
Incorrect redirect
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The guard instead follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application starts
       ↓
Authentication restoration starts
       ↓
Protected route waits for readiness
       ↓
Authentication decision
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;strong&gt;A small profile detail&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;profile()&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; until &lt;code&gt;loadProfile()&lt;/code&gt; is called.&lt;/p&gt;

&lt;p&gt;Adapters load the profile lazily, so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;won't display anything until the profile has actually been loaded.&lt;/p&gt;

&lt;p&gt;This is intentional. Authentication state and profile loading are different concerns.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Designing around&lt;/strong&gt; &lt;code&gt;AuthProvider&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Once I decided that the application shouldn't depend directly on Keycloak, I needed an abstraction.&lt;/p&gt;

&lt;p&gt;That's &lt;code&gt;AuthProvider&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The architecture looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                   Angular Application
                           |
                           v
                      AuthService
                           |
                           v
                      AuthProvider
                       /        \\
                      v          v
              Keycloak       Other Provider
               Adapter          Adapter

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application interacts with the authentication layer.&lt;/p&gt;

&lt;p&gt;The authentication layer interacts with an &lt;code&gt;AuthProvider&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The provider handles the identity-provider-specific implementation.&lt;/p&gt;

&lt;p&gt;The port is deliberately small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AuthProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Signal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Signal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Claims&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Signal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;LoginOptions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;LogoutOptions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Being able to show the entire contract on one screen is part of the argument.&lt;/p&gt;

&lt;p&gt;This is the entire surface that the core, the guard, and the interceptor are allowed to depend on.&lt;/p&gt;

&lt;p&gt;There is one rule that shaped the split:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;State is signals, operations are promises.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The distinction is state versus action.&lt;/p&gt;

&lt;p&gt;For example, authentication status is state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Token refresh is an operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A token refresh shouldn't become another piece of application state that every consumer needs to understand.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Keycloak is an adapter, not the architecture&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;I intentionally didn't make Keycloak the center of the library.&lt;/p&gt;

&lt;p&gt;It's a provider adapter exposed through its own entry point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ngx-auth-client
│
├── Core
│   ├── AuthService
│   ├── AuthProvider
│   ├── authGuard
│   └── authTokenInterceptor
│
├── Keycloak
│   └── KeycloakAuthProvider
│
└── Testing
    └── FakeAuthProvider
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is essentially the &lt;strong&gt;Ports and Adapters&lt;/strong&gt; pattern.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AuthProvider&lt;/code&gt; is the port.&lt;/p&gt;

&lt;p&gt;The provider implementations are adapters.&lt;/p&gt;

&lt;p&gt;Identity providers are infrastructure. The application should depend on &lt;strong&gt;authentication concepts&lt;/strong&gt;, not infrastructure-specific APIs.&lt;/p&gt;

&lt;p&gt;If tomorrow the application needs another identity provider, the goal is to implement another adapter rather than rewrite authentication logic throughout the application.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The boundary isn't only TypeScript&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;This is the part I care about most.&lt;/p&gt;

&lt;p&gt;A provider abstraction isn't very useful if the package itself still forces every consumer to install the provider SDK.&lt;/p&gt;

&lt;p&gt;That's why the package structure matters.&lt;/p&gt;

&lt;p&gt;The core package has &lt;strong&gt;no identity-provider dependency&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;keycloak-js&lt;/code&gt; is an optional peer dependency and is only required by:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;ismailza&lt;/span&gt;&lt;span class="sr"&gt;/ngx-auth-client/&lt;/span&gt;&lt;span class="nx"&gt;keycloak&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an application never imports the Keycloak entry point, Keycloak isn't part of its dependency graph.&lt;/p&gt;

&lt;p&gt;So the separation exists at two levels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Architecture
    ↓
AuthProvider abstraction
    ↓
Provider adapters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Package
    ↓
Core entry point
    ↓
Optional provider entry points
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the difference between &lt;strong&gt;claiming a separation&lt;/strong&gt; and actually shipping one.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Capabilities instead of one giant interface&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;There's a familiar failure mode in authentication abstractions.&lt;/p&gt;

&lt;p&gt;It's tempting to create one huge interface containing every possible capability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AuthProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;changePassword&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;linkAccount&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But not every identity provider supports every operation.&lt;/p&gt;

&lt;p&gt;Folding every provider's features into one interface forces adapters to implement methods they cannot support.&lt;/p&gt;

&lt;p&gt;That's how multi-provider abstractions eventually become a lowest common denominator — followed by a &lt;code&gt;getNativeClient()&lt;/code&gt; escape hatch.&lt;/p&gt;

&lt;p&gt;I wanted to avoid that.&lt;/p&gt;

&lt;p&gt;The core provider stays small, and additional functionality is represented as optional capabilities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SupportsRegistration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;RegisterOptions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SupportsAccountManagement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;accountManagement&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SupportsPasswordUpdate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;updatePassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;PasswordUpdateOptions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SupportsProfile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Signal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UserProfile&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;loadProfile&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UserProfile&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core detects these capabilities through type guards.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AuthService&lt;/code&gt; then exposes the result as signals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canRegister&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canManageAccount&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canUpdatePassword&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canLoadProfile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That has a practical benefit.&lt;/p&gt;

&lt;p&gt;The application can hide UI that the configured provider cannot actually deliver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canRegister&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nf"&gt;button &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;click&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auth.register()&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;Create&lt;/span&gt; &lt;span class="nx"&gt;an&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if application code calls an unsupported capability anyway, the library throws an &lt;code&gt;UnsupportedCapabilityError&lt;/code&gt; that identifies the capability and the corresponding &lt;code&gt;can*&lt;/code&gt; check.&lt;/p&gt;

&lt;p&gt;Not a silent no-op.&lt;/p&gt;

&lt;p&gt;Not a confusing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;undefined is not a function
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Keycloak adapter currently implements all four capabilities because Keycloak supports them.&lt;/p&gt;

&lt;p&gt;It isn't stubbing methods just to satisfy a contract.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't force every authentication provider to implement capabilities it doesn't support.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Role normalization is what makes the port credible&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's where "provider-agnostic" stops being a claim and becomes something you can point at.&lt;/p&gt;

&lt;p&gt;Identity providers disagree about where roles live.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Keycloak can expose roles through &lt;code&gt;realm_access&lt;/code&gt; and &lt;code&gt;resource_access&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Auth0 commonly uses a namespaced custom claim&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cognito can use &lt;code&gt;cognito:groups&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Microsoft Entra ID can expose application roles through &lt;code&gt;roles&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If that provider-specific structure reaches your Angular components, the abstraction has already failed.&lt;/p&gt;

&lt;p&gt;So the rule is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Role normalization belongs in the adapter.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The core only sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Signal&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything above the port speaks in roles.&lt;/p&gt;

&lt;p&gt;Everything below it speaks the provider's vocabulary.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Keycloak role extraction&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;For Keycloak, the adapter can be configured to extract realm and resource roles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;withKeycloak&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;realm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;realm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;resource: true&lt;/code&gt; takes roles from every client.&lt;/p&gt;

&lt;p&gt;An explicit array takes roles only from the clients you name.&lt;/p&gt;

&lt;p&gt;That distinction matters when several clients contain roles with the same name. Explicitly selecting the resource clients makes the authorization boundary easier to reason about.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Custom role mapping&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;If roles live somewhere non-standard, extraction can be overridden:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;withKeycloak&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;realm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;mapRoles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;groups&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the rest of the application doesn't care where those roles came from.&lt;/p&gt;

&lt;p&gt;That is the value of the adapter.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Route authorization: functional guards,&lt;/strong&gt; &lt;code&gt;anyOf&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;allOf&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Authentication answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Who are you?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Authorization answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What are you allowed to access?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Knowing that a user is authenticated tells us nothing about whether they can access an administration page.&lt;/p&gt;

&lt;p&gt;That's what the route guard handles.&lt;/p&gt;

&lt;p&gt;Angular's functional APIs provide a clean way to express authorization through route metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;canActivate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;authGuard&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;anyOf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;owner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;anyOf&lt;/code&gt; means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;admin OR owner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;allOf&lt;/code&gt; requires every listed role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;billing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;canActivate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;authGuard&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;allOf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;finance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;admin AND finance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps authorization requirements close to the route instead of scattering authorization logic throughout components.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;What the guard actually does&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The route configuration is the visible part.&lt;/p&gt;

&lt;p&gt;The behavior underneath it is just as important.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;1. It waits for authentication readiness&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The guard awaits &lt;code&gt;whenReady()&lt;/code&gt; before deciding whether the route can be activated.&lt;/p&gt;

&lt;p&gt;This avoids a common hard-refresh race:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Page reload
   ↓
Session restoration starts
   ↓
Guard runs immediately
   ↓
authenticated = false
   ↓
Incorrect login redirect
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Page reload
   ↓
Session restoration starts
   ↓
Guard waits
   ↓
Session restoration settles
   ↓
Authorization decision
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;strong&gt;2. Unauthenticated users are redirected to login&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;When a user isn't authenticated, the guard redirects to the login flow with a return URL.&lt;/p&gt;

&lt;p&gt;The goal is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User requests /admin
       ↓
Not authenticated
       ↓
Login
       ↓
Return to /admin

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;strong&gt;3. Authenticated but unauthorized users are forbidden&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A user can be authenticated and still lack the required permissions.&lt;/p&gt;

&lt;p&gt;Those users are sent to the configured &lt;code&gt;forbiddenRoute&lt;/code&gt;, which defaults to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/forbidden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also set it to &lt;code&gt;null&lt;/code&gt; if you want the guard to refuse activation without performing navigation.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;4. Non-browser environments fail closed&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;On a non-browser platform, the guard returns &lt;code&gt;false&lt;/code&gt; without attempting a login redirect.&lt;/p&gt;

&lt;p&gt;Because the tokens are held in memory, there is no browser session available for the server to evaluate.&lt;/p&gt;

&lt;p&gt;Rendering protected content into an SSR response could also create caching problems if that response is later served to another user.&lt;/p&gt;

&lt;p&gt;Failing closed is therefore the safer behavior.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Requirements accumulate through the route tree&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;There's another detail that matters.&lt;/p&gt;

&lt;p&gt;The guard doesn't look only at the leaf route.&lt;/p&gt;

&lt;p&gt;It walks the route tree and accumulates authorization requirements.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;canActivate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;authGuard&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;anyOf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;owner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;billing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;allOf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;finance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;loadComponent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./billing.component&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The billing route inherits the parent requirement and adds its own.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;admin OR owner
        AND
      finance

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is an important subtlety here.&lt;/p&gt;

&lt;p&gt;Each &lt;code&gt;anyOf&lt;/code&gt; group remains separate.&lt;/p&gt;

&lt;p&gt;Suppose we have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Parent:
anyOf(admin, owner)

Child:
anyOf(finance, billing)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are two independent requirements.&lt;/p&gt;

&lt;p&gt;They should not be flattened into:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;anyOf(admin, owner, finance, billing)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Otherwise a user with only &lt;code&gt;admin&lt;/code&gt; would satisfy the combined requirement.&lt;/p&gt;

&lt;p&gt;Keeping the groups separate preserves the intended authorization semantics.&lt;/p&gt;

&lt;p&gt;It's a small implementation detail with a large security consequence.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;The token interceptor should fail closed&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Route protection is only half the story.&lt;/p&gt;

&lt;p&gt;Once a user is authenticated, the application needs to attach an access token to API requests.&lt;/p&gt;

&lt;p&gt;A naive interceptor might attach the token to every outgoing request.&lt;/p&gt;

&lt;p&gt;I don't think that's a good default.&lt;/p&gt;

&lt;p&gt;An Angular application can communicate with many destinations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;your backend API&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;third-party APIs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;analytics services&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;CDNs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;external resources&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don't want an access token accidentally attached to an unrelated destination.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;ngx-auth-client&lt;/code&gt; uses an &lt;strong&gt;allowlist&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request
   |
   v
Does the URL match the allowlist?
   |
   +---- No ----&amp;gt; Send unchanged
   |
   +---- Yes ---&amp;gt; Attach bearer token

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default pattern is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="o"&gt;/^&lt;/span&gt;&lt;span class="err"&gt;\\&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;\\&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)?&lt;/span&gt;&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means same-origin relative &lt;code&gt;/api/*&lt;/code&gt; requests.&lt;/p&gt;

&lt;p&gt;You can configure a more specific URL pattern or restrict injection by HTTP method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;provideAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;withKeycloak&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;realm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;clientId&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;bearer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;urlPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/^https:&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;\\&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="err"&gt;\\&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;example&lt;/span&gt;&lt;span class="err"&gt;\\&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;com&lt;/span&gt;&lt;span class="err"&gt;\\&lt;/span&gt;&lt;span class="c1"&gt;//,&lt;/span&gt;
      &lt;span class="na"&gt;methods&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also disable bearer injection completely with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;bearer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;when another layer is responsible for authorization headers.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The interceptor waits for readiness&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A request fired during the initial authentication restore shouldn't race the session.&lt;/p&gt;

&lt;p&gt;The interceptor waits for authentication readiness before deciding whether a token can be attached.&lt;/p&gt;

&lt;p&gt;Otherwise you can get the classic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application starts
       ↓
API request fires
       ↓
Authentication is still restoring
       ↓
Request goes out without token
       ↓
401
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interceptor avoids that race.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Unauthenticated requests remain unauthenticated&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;If there is no authenticated session, the request proceeds without a bearer token.&lt;/p&gt;

&lt;p&gt;The API can then return &lt;code&gt;401 Unauthorized&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The client doesn't need to turn every unauthenticated request into a client-side exception.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Regular expressions can have state&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;One small implementation detail caused me to think more carefully about the interceptor.&lt;/p&gt;

&lt;p&gt;JavaScript regular expressions with the &lt;code&gt;g&lt;/code&gt; or &lt;code&gt;y&lt;/code&gt; flags maintain &lt;code&gt;lastIndex&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That means repeatedly calling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;can produce alternating results.&lt;/p&gt;

&lt;p&gt;A pattern can effectively behave like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;match
no match
match
no match
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's an especially nasty failure mode for an authentication interceptor because the symptom is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Why is my Authorization header missing from every other request?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the library strips &lt;code&gt;g&lt;/code&gt; and &lt;code&gt;y&lt;/code&gt; flags from configured patterns before matching.&lt;/p&gt;

&lt;p&gt;The underlying principle is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Only attach credentials where they are explicitly expected.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Why keep tokens in memory?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ngx-auth-client&lt;/code&gt; doesn't persist access tokens in &lt;code&gt;localStorage&lt;/code&gt; or &lt;code&gt;sessionStorage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;More precisely, the library itself doesn't store the token in browser-persistent storage. With the Keycloak adapter, &lt;code&gt;keycloak-js&lt;/code&gt; holds the token in memory.&lt;/p&gt;

&lt;p&gt;At first, this raises an obvious question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What happens after a page reload?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The session can still be restored because the Keycloak adapter uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;onLoad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;check-sso&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser doesn't need to persist the access token itself. Keycloak's SSO session can be checked again when the application initializes.&lt;/p&gt;

&lt;p&gt;The reason for avoiding persistent token storage is straightforward.&lt;/p&gt;

&lt;p&gt;Browser-accessible persistent storage can increase the impact of a successful XSS attack because malicious JavaScript may be able to read stored credentials.&lt;/p&gt;

&lt;p&gt;Keeping tokens in memory doesn't make an application immune to XSS.&lt;/p&gt;

&lt;p&gt;No authentication architecture can compensate for an insecure application.&lt;/p&gt;

&lt;p&gt;But it does reduce the places where credentials are persistently stored.&lt;/p&gt;

&lt;p&gt;That's a trade-off I prefer for this kind of client-side authentication layer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't persist credentials unless persistence is actually required.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;PKCE — and making the default survive real configuration&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;For browser-based applications, authorization-code flow with PKCE is the modern OAuth approach.&lt;/p&gt;

&lt;p&gt;The Keycloak adapter defaults to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;pkceMethod&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;S256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I want to be precise about this.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ngx-auth-client&lt;/code&gt; doesn't implement the OAuth authorization-code exchange itself. &lt;code&gt;keycloak-js&lt;/code&gt; handles that.&lt;/p&gt;

&lt;p&gt;The library chooses the default and makes sure the default survives the way real applications build configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The&lt;/strong&gt; &lt;code&gt;undefined&lt;/code&gt; &lt;strong&gt;configuration problem&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Configuration objects are often assembled dynamically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;pkceMethod&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pkceMethod&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;environment.pkceMethod&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, a later object spread can accidentally overwrite the default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;pkceMethod&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;S256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting value becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;pkceMethod&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The secure default has effectively disappeared.&lt;/p&gt;

&lt;p&gt;So the adapter removes &lt;code&gt;undefined&lt;/code&gt; values before merging configuration.&lt;/p&gt;

&lt;p&gt;It's a small implementation detail, but it's an important lesson:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Security defaults aren't only about choosing the right value. They're also about making sure the value survives real configuration.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Token refresh should be invisible to the application&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;I didn't want token-refresh logic spread throughout the application.&lt;/p&gt;

&lt;p&gt;A component shouldn't need to contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokenIsAboutToExpire&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's infrastructure logic.&lt;/p&gt;

&lt;p&gt;The application should simply ask for an access token.&lt;/p&gt;

&lt;p&gt;The authentication layer handles the lifecycle.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application
    |
    | getToken()
    v
AuthProvider
    |
    | Token still valid?
    |       |
    |       +---- Yes ---&amp;gt; return token
    |
    |       +---- No ----&amp;gt; refresh
    |
    v
Return valid token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getToken()&lt;/code&gt; only needs to interact with the network when the token is close to expiry.&lt;/p&gt;

&lt;p&gt;With the Keycloak adapter, the default minimum token validity is 30 seconds.&lt;/p&gt;

&lt;p&gt;Otherwise, the existing token can be returned immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What happens when refresh fails?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;This is where the signal-based architecture becomes useful again.&lt;/p&gt;

&lt;p&gt;If the session genuinely expires and refresh fails, the adapter clears authentication state through the provider's authentication events.&lt;/p&gt;

&lt;p&gt;The result is a state change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;authenticated()
    true
      ↓
    false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of the application doesn't need to catch a Keycloak-specific error just to update its UI.&lt;/p&gt;

&lt;p&gt;The application is already reacting to authentication state.&lt;/p&gt;

&lt;p&gt;That's what I mean by making token lifecycle management invisible: the infrastructure handles the lifecycle while the application sees the resulting state.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Testing without Keycloak&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Authentication tests become painful when they depend on a real identity provider.&lt;/p&gt;

&lt;p&gt;You don't want every unit test to require:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Angular Test
     ↓
Keycloak
     ↓
Realm
     ↓
Client
     ↓
User
     ↓
Token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's too much infrastructure for a unit test.&lt;/p&gt;

&lt;p&gt;So the library provides a fake authentication provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;TestBed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configureTestingModule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;provideAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nf"&gt;withFakeAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Test User&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is that the fake provider implements the &lt;strong&gt;same provider port&lt;/strong&gt; as the production adapter.&lt;/p&gt;

&lt;p&gt;The component doesn't know whether authentication came from Keycloak or the fake.&lt;/p&gt;

&lt;p&gt;That's exactly what we want from an abstraction.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The fake provider is more than a stub&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;It can also record calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;loginCalls
logoutCalls
registerCalls
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means tests can assert not only that a guard denied access, but also that the expected login flow was triggered with the correct information.&lt;/p&gt;

&lt;p&gt;It can also simulate failures such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;failInit
failToken
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes it possible to test failure paths without depending on a real identity provider.&lt;/p&gt;

&lt;p&gt;And the fake provider can change state during a test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fake&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TestBed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FakeAuthProvider&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRoles&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;viewer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes it possible to test how the application reacts to changing authentication state.&lt;/p&gt;

&lt;p&gt;There is also a useful architectural side effect here.&lt;/p&gt;

&lt;p&gt;The fake provider is a test of the abstraction itself.&lt;/p&gt;

&lt;p&gt;If a fake implementation is difficult to build without knowing Keycloak internals, the port probably isn't truly provider-agnostic.&lt;/p&gt;

&lt;p&gt;Writing the fake helped validate that boundary.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Signals are particularly important for zoneless Angular&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;I've described signals as a design choice.&lt;/p&gt;

&lt;p&gt;For zoneless Angular applications, the distinction becomes more important.&lt;/p&gt;

&lt;p&gt;Consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keycloak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;versus:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first approach exposes a mutable property from the identity-provider client.&lt;/p&gt;

&lt;p&gt;The second exposes a signal that Angular understands as reactive state.&lt;/p&gt;

&lt;p&gt;When the identity provider reports an authentication event, the adapter converts that event into a signal update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Identity provider event
        ↓
Adapter
        ↓
Signal update
        ↓
Angular reactivity
        ↓
UI / Guards / Application logic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example, the Keycloak adapter can react to events such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;authentication success&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;token refresh success&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;logout&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;token expiration&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The adapter is the single place where those provider-specific events become Angular state.&lt;/p&gt;

&lt;p&gt;Nothing downstream needs to understand how Keycloak reports them.&lt;/p&gt;

&lt;p&gt;That's the layer I wanted to write once and reuse.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Compatibility is part of the library&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Building an Angular library isn't only about making the code work on your machine.&lt;/p&gt;

&lt;p&gt;If you're publishing a package, you also need to think about the environments consuming it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ngx-auth-client&lt;/code&gt; supports &lt;strong&gt;Angular 17 through 22 from a single release&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That raises an important question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do you know that the package you publish actually works?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Validating the repository source isn't enough.&lt;/p&gt;

&lt;p&gt;I want to validate the &lt;strong&gt;artifact users install&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Source
  ↓
Build
  ↓
npm pack
  ↓
Package artifact
  ↓
Install in each supported Angular version
  ↓
Build + type-check + test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A repository can be perfectly healthy while the published package has problems with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;package exports&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;generated declarations&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;build output&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;dependencies&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;peer dependencies&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;packaging configuration&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an open-source library, the package is the product.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The compatibility matrix comes from the package&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The supported Angular versions are derived from &lt;code&gt;peerDependencies&lt;/code&gt; rather than maintained as a completely separate list.&lt;/p&gt;

&lt;p&gt;That reduces the risk of having documentation or CI claim support for versions that the package doesn't actually declare.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Supporting Angular 17–22 creates real constraints&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Supporting six Angular majors from a single release isn't free.&lt;/p&gt;

&lt;p&gt;One important decision was to build the Keycloak integration directly on &lt;code&gt;keycloak-js&lt;/code&gt; instead of &lt;code&gt;keycloak-angular&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because a wrapper that tracks a specific Angular major would make a broad compatibility range much harder to maintain.&lt;/p&gt;

&lt;p&gt;Owning the Angular integration gives the library more control over its supported range.&lt;/p&gt;

&lt;p&gt;There is also an example of what compatibility work actually looks like in the implementation.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;provideAuth()&lt;/code&gt; uses &lt;code&gt;ENVIRONMENT_INITIALIZER&lt;/code&gt;, which is deprecated in newer Angular versions.&lt;/p&gt;

&lt;p&gt;Its replacement, &lt;code&gt;provideEnvironmentInitializer&lt;/code&gt;, isn't available in the oldest Angular versions supported by the library.&lt;/p&gt;

&lt;p&gt;So for a library targeting Angular 17-22, the practical choice is to use the API that remains available across the entire supported range.&lt;/p&gt;

&lt;p&gt;It's not always about using the newest API.&lt;/p&gt;

&lt;p&gt;Sometimes compatibility means deliberately choosing the API that works across the versions you promise to support.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Documentation is part of the product&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A library isn't finished when the implementation works.&lt;/p&gt;

&lt;p&gt;Developers need to understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;what problem it solves&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;how to install it&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;how to configure it&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;how authentication works&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;how to configure guards&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;how to configure the interceptor&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;how to use the Keycloak adapter&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;how to test it&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's why I created a dedicated documentation website for &lt;code&gt;ngx-auth-client&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The repository contains the implementation.&lt;/p&gt;

&lt;p&gt;The documentation site provides the structured entry point for developers who want to evaluate and use the library.&lt;/p&gt;

&lt;p&gt;You can explore both here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ismailza/ngx-auth-client" rel="noopener noreferrer"&gt;https://github.com/ismailza/ngx-auth-client&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Documentation:&lt;/strong&gt; &lt;a href="https://ismailza.github.io/ngx-auth-client" rel="noopener noreferrer"&gt;https://ismailza.github.io/ngx-auth-client&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;What I learned building it&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Building &lt;code&gt;ngx-auth-client&lt;/code&gt; reinforced a few principles for me.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;1. An abstraction should remove coupling, not hide complexity&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;If all I've done is wrap Keycloak APIs with different names, I haven't created a useful abstraction.&lt;/p&gt;

&lt;p&gt;I've created a translation layer.&lt;/p&gt;

&lt;p&gt;The real test is whether the application can stop knowing Keycloak exists.&lt;/p&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;2. Reactive state should be exposed as reactive state&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Angular provides signals as a first-class reactive primitive.&lt;/p&gt;

&lt;p&gt;Authentication state is a natural candidate for them.&lt;/p&gt;

&lt;p&gt;Instead of making every application consumer translate provider-specific events into Angular state, the adapter should do that once.&lt;/p&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;3. Provider differences belong at the boundary&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Roles are a good example.&lt;/p&gt;

&lt;p&gt;The application wants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It doesn't want to know about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;realm_access&lt;/span&gt;
&lt;span class="nx"&gt;resource_access&lt;/span&gt;
&lt;span class="nx"&gt;cognito&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nx"&gt;groups&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That transformation belongs in the adapter.&lt;/p&gt;

&lt;p&gt;The same principle can be applied to other provider-specific concepts.&lt;/p&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;4. Security defaults need to survive real usage&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;It's easy to say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"PKCE is enabled by default."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's harder to make sure an application's configuration cannot accidentally erase that default with &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same applies to token injection.&lt;/p&gt;

&lt;p&gt;An allowlist is safer than assuming every outgoing request is trusted.&lt;/p&gt;




&lt;h3&gt;
  
  
  &lt;strong&gt;5. Testability is an architectural consequence&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The fake provider wasn't added simply because testing is convenient.&lt;/p&gt;

&lt;p&gt;It became possible because the provider boundary is real.&lt;/p&gt;

&lt;p&gt;The application can be tested against the same abstraction used in production.&lt;/p&gt;

&lt;p&gt;That's one of the best signals that an abstraction is actually useful.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;What's next?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ngx-auth-client&lt;/code&gt; is still evolving.&lt;/p&gt;

&lt;p&gt;There are several areas I want to continue exploring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;additional provider adapters&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;richer authorization capabilities&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;better testing utilities&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;API evolution without unnecessary breaking changes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;compatibility as new Angular versions are released&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But additional providers are probably the most interesting test.&lt;/p&gt;

&lt;p&gt;It's easy to claim that an architecture is provider-agnostic when there is only one provider.&lt;/p&gt;

&lt;p&gt;The real test is implementing a second one without changing the application-facing API.&lt;/p&gt;

&lt;p&gt;That's something I want the project to prove over time.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Final thoughts&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;I didn't build &lt;code&gt;ngx-auth-client&lt;/code&gt; because Angular authentication was impossible without another library.&lt;/p&gt;

&lt;p&gt;I built it because I wanted authentication to have a cleaner architectural boundary in my applications.&lt;/p&gt;

&lt;p&gt;The main idea is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Your Angular application should consume authentication capabilities, not depend directly on an identity provider.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Signals make authentication state feel like native Angular state.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AuthProvider&lt;/code&gt; keeps the application independent from the identity provider.&lt;/p&gt;

&lt;p&gt;Role normalization keeps provider-specific claim structures out of application code.&lt;/p&gt;

&lt;p&gt;Functional guards make authorization declarative.&lt;/p&gt;

&lt;p&gt;An allowlisted interceptor limits where credentials are sent.&lt;/p&gt;

&lt;p&gt;In-memory token handling avoids unnecessary persistent credential storage.&lt;/p&gt;

&lt;p&gt;And a fake provider makes authentication-dependent code easier to test.&lt;/p&gt;

&lt;p&gt;The result isn't simply a Keycloak wrapper.&lt;/p&gt;

&lt;p&gt;It's an attempt to build an &lt;strong&gt;Angular-native authentication layer&lt;/strong&gt; that can evolve independently from the identity provider underneath it.&lt;/p&gt;

&lt;p&gt;If you're interested in the implementation, the project is open source:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ismailza/ngx-auth-client" rel="noopener noreferrer"&gt;https://github.com/ismailza/ngx-auth-client&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documentation:&lt;/strong&gt; &lt;a href="https://ismailza.github.io/ngx-auth-client" rel="noopener noreferrer"&gt;https://ismailza.github.io/ngx-auth-client&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And if you're building Angular libraries yourself, I'd be interested in how you approach authentication, compatibility, and provider abstraction.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://zyvop.com/i-built-an-angular-authentication-layer-for-the-signals-era-ge196" rel="noopener noreferrer"&gt;ZyVOP&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;💡 For more articles like this, &lt;a href="https://zyvop.com/newsletter" rel="noopener noreferrer"&gt;subscribe to the ZyVOP newsletter&lt;/a&gt;!&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>security</category>
      <category>authentication</category>
      <category>angular</category>
    </item>
    <item>
      <title>Building a Reusable Keycloak Theme Architecture</title>
      <dc:creator>Ismail ZAHIR</dc:creator>
      <pubDate>Sat, 15 Aug 2026 22:14:39 +0000</pubDate>
      <link>https://dev.to/ismailzahir/building-a-reusable-keycloak-theme-architecture-1b9h</link>
      <guid>https://dev.to/ismailzahir/building-a-reusable-keycloak-theme-architecture-1b9h</guid>
      <description>&lt;p&gt;The last article in this series ended with a promise: make Keycloak stop looking like Keycloak. I kept it. The login pages got a custom template and stylesheet, and the roughly seventeen emails Keycloak sends got the same treatment.&lt;/p&gt;

&lt;p&gt;It worked. I wasn't satisfied.&lt;/p&gt;

&lt;p&gt;The login theme carried its own styles. The email theme carried its own resources. If I wanted a second branded version of the same authentication experience — another product, another client, another environment — I would have been copying files and hoping I remembered to update both.&lt;/p&gt;

&lt;p&gt;That turned a styling exercise into an engineering question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do you build Keycloak themes once, reuse the common parts, and still let every application customize what it actually needs?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is the answer I arrived at, and it is part of my &lt;strong&gt;Engineering in Practice&lt;/strong&gt; series, where I document the decisions and the reasoning behind real projects rather than the finished result alone.&lt;/p&gt;

&lt;p&gt;The implementation lives in &lt;a href="https://github.com/ismailza/keycloak-modern-auth" rel="noopener noreferrer"&gt;&lt;code&gt;keycloak-modern-auth&lt;/code&gt;&lt;/a&gt;, the companion repository that evolves alongside these articles.&lt;/p&gt;

&lt;h2&gt;
  
  
  From customization to architecture
&lt;/h2&gt;

&lt;p&gt;In the earlier work, the goal was simple: customize Keycloak. Replace the default login screen with a custom design, then do the same for the emails.&lt;/p&gt;

&lt;p&gt;That's perfectly adequate when you have one application and one visual identity. But real systems grow.&lt;/p&gt;

&lt;p&gt;You end up with multiple applications, multiple brands, several environments, or a few products sharing one identity provider — and each of them wants its own login experience and its own email branding.&lt;/p&gt;

&lt;p&gt;At that point, duplicating templates and CSS across themes stops being convenient and starts being a liability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;theme-a/
├── login/
│   ├── template.ftl
│   └── resources/css/login.css
└── email/
    ├── html/template.ftl
    └── messages/

theme-b/
├── login/
│   ├── template.ftl        ← same file, second copy
│   └── resources/css/login.css
└── email/
    ├── html/template.ftl
    └── messages/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At two themes this looks harmless.&lt;/p&gt;

&lt;p&gt;Then someone asks to change the border radius, or the input height, or the focus ring — and you have to remember every theme that contains a copy, and update them consistently.&lt;/p&gt;

&lt;p&gt;Duplication that started as convenience has become an architectural problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: a reusable base theme
&lt;/h2&gt;

&lt;p&gt;Instead of treating every Keycloak theme as an isolated implementation, I wanted a common layer underneath them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;               Base theme
                    │
         ┌──────────┴──────────┐
         │                     │
   Application A         Application B
         │                     │
     overrides             overrides
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The base theme holds the authentication design. Derived themes hold only what makes them different.&lt;/p&gt;

&lt;p&gt;This gives two properties that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reuse&lt;/strong&gt;, because the shared templates and stylesheets exist in exactly one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customization&lt;/strong&gt;, because a derived theme can still override the parts that genuinely need to change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's the same instinct as inheritance in code: put the common behaviour in the base, specialize where necessary.&lt;/p&gt;

&lt;p&gt;The interesting part is that Keycloak already gives you the mechanism — you just have to use it deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Keycloak actually resolves themes
&lt;/h2&gt;

&lt;p&gt;A theme declares its parent in &lt;code&gt;theme.properties&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;modern.base&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single line is doing more than it looks like, because Keycloak resolves the four kinds of theme content by four different rules — and knowing which is which is most of the battle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Properties merge vertically.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A child's &lt;code&gt;theme.properties&lt;/code&gt; is layered over its parent's. Anything the child doesn't set, it inherits. This is the mechanism that makes token overriding work at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Templates resolve child-first, and whole-file.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the child ships &lt;code&gt;template.ftl&lt;/code&gt;, its version is used entirely; if it doesn't, the parent's is used entirely. There is no partial override, no block merging.&lt;/p&gt;

&lt;p&gt;You inherit a template or you replace it — which is a good reason to keep base templates generic enough that nobody needs to replace them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resources resolve per file.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Drop a file at the same relative path and it shadows the parent's. This is how a child theme swaps &lt;code&gt;img/logo.svg&lt;/code&gt; without touching a single line of CSS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;import=&lt;/code&gt; reaches sideways.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Inheritance runs vertically, from child to parent, within one theme type.&lt;/p&gt;

&lt;p&gt;But a login theme and an email theme are different types, and both need the same brand colour. &lt;code&gt;import=&lt;/code&gt; connects them, so shared values can live in a &lt;code&gt;common&lt;/code&gt; theme that both types pull from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;modern.base/login  ←import←  modern.base/common
       ↑ parent                      ↑ parent
    acme/login     ←import←       acme/common
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That diagram is the whole architecture.&lt;/p&gt;

&lt;p&gt;Vertical arrows are inheritance; horizontal arrows are sharing across types.&lt;/p&gt;

&lt;p&gt;In the repository, &lt;code&gt;modern.base&lt;/code&gt; is the foundation and &lt;code&gt;acme&lt;/code&gt; is a worked example of a child brand — one properties file and a logo.&lt;/p&gt;

&lt;p&gt;This is also why I extend Keycloak's theme system rather than editing the built-in themes: the built-ins move underneath you on every upgrade, and you own the diff forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What belongs in the base theme
&lt;/h2&gt;

&lt;p&gt;Getting the mechanism right is the easy half.&lt;/p&gt;

&lt;p&gt;The harder question is what should actually live in the base — because a base theme that accumulates every possible customization is just a large theme with extra steps.&lt;/p&gt;

&lt;p&gt;I settled on a simple rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The base owns structure, the child owns identity.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The base theme holds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;design tokens&lt;/li&gt;
&lt;li&gt;typography scale&lt;/li&gt;
&lt;li&gt;page layout&lt;/li&gt;
&lt;li&gt;form and input styling&lt;/li&gt;
&lt;li&gt;buttons&lt;/li&gt;
&lt;li&gt;alerts and messages&lt;/li&gt;
&lt;li&gt;shared assets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It answers the question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How does the authentication interface work and look by default?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The derived theme holds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;logo&lt;/li&gt;
&lt;li&gt;brand colours&lt;/li&gt;
&lt;li&gt;favicon&lt;/li&gt;
&lt;li&gt;product-specific assets&lt;/li&gt;
&lt;li&gt;any genuinely necessary override&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It answers a much narrower question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How does this particular product want to be recognized?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That boundary is what keeps the abstraction honest.&lt;/p&gt;

&lt;p&gt;If a child theme starts reaching for the structural layer, that's a signal the base is missing something — not a signal to fork the template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design tokens, and the loop that generates them
&lt;/h2&gt;

&lt;p&gt;The piece that makes the split practical is design tokens.&lt;/p&gt;

&lt;p&gt;Without them, values scatter through the stylesheet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.kc-button-primary&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4f46e5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.kc-input&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#d1d5db&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the brand colour and you are grepping.&lt;/p&gt;

&lt;p&gt;With tokens, the values are declared once and consumed everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--kc-color-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4f46e5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--kc-color-primary-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4338ca&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--kc-color-surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--kc-color-text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#111827&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--kc-color-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#d1d5db&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--kc-radius-md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--kc-space-md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.kc-button-primary&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--kc-color-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--kc-radius-md&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part I like, though, isn't the CSS.&lt;/p&gt;

&lt;p&gt;It's where the values come from.&lt;/p&gt;

&lt;p&gt;Rather than hand-writing that &lt;code&gt;:root&lt;/code&gt; block, the base template walks the theme's properties and emits a custom property for every key that starts with &lt;code&gt;kcToken&lt;/code&gt;, converting camelCase to kebab-case along the way.&lt;/p&gt;

&lt;p&gt;So this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;kcTokenColorPrimary&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="c"&gt;#0d9488
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;becomes this, at render time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--kc-color-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0d9488&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop is generic.&lt;/p&gt;

&lt;p&gt;It enumerates properties; it doesn't know their names.&lt;/p&gt;

&lt;p&gt;That detail carries more weight than it first appears: a child theme that invents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;kcTokenBannerHeight&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;4rem&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;--kc-banner-height&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;for free, with no change to the template and no change to the base.&lt;/p&gt;

&lt;p&gt;The extension point is the properties file, not the code.&lt;/p&gt;

&lt;p&gt;The base currently declares 58 tokens plus 24 dark-mode overrides, grouped roughly by colour scheme, surfaces, brand, status colours, typography, shape, spacing and layout.&lt;/p&gt;

&lt;p&gt;Dark mode reuses the same variable names inside a &lt;code&gt;prefers-color-scheme&lt;/code&gt; block, and only colours are overridden — spacing, radii and typography are shared, because a login form shouldn't change shape when the sun goes down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters across brands
&lt;/h2&gt;

&lt;p&gt;Now put inheritance and tokens together.&lt;/p&gt;

&lt;p&gt;Two products, one authentication architecture.&lt;/p&gt;

&lt;p&gt;The base defines the full token set.&lt;/p&gt;

&lt;p&gt;Brand A overrides one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;kcTokenColorPrimary&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="c"&gt;#2563eb
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Brand B overrides one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;kcTokenColorPrimary&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="c"&gt;#7c3aed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The components don't change.&lt;/p&gt;

&lt;p&gt;The templates don't change.&lt;/p&gt;

&lt;p&gt;The authentication flow certainly doesn't change.&lt;/p&gt;

&lt;p&gt;In practice a child theme is a &lt;code&gt;parent=&lt;/code&gt;, an &lt;code&gt;import=&lt;/code&gt;, a handful of colours, a logo file, and a message bundle entry for the name shown in the admin console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                Base theme
                    │
            Shared components
                    │
              Design tokens
                    │
         ┌──────────┴──────────┐
         │                     │
      Brand A               Brand B
         │                     │
      Blue UI              Purple UI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly the reuse I was looking for, and it is worth being precise about why it works:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The tokens are the only public surface between the base and its children. Everything else is implementation detail.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The email theme, where none of this is allowed
&lt;/h2&gt;

&lt;p&gt;Login themes are rendered in a browser.&lt;/p&gt;

&lt;p&gt;Emails are rendered by mail clients, and mail clients are a different century.&lt;/p&gt;

&lt;p&gt;No external stylesheets — clients drop &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; entirely.&lt;/p&gt;

&lt;p&gt;No CSS custom properties — Gmail strips &lt;code&gt;var()&lt;/code&gt;, so the entire mechanism above simply evaporates.&lt;/p&gt;

&lt;p&gt;Outlook's Word engine only reliably understands pixels.&lt;/p&gt;

&lt;p&gt;Flexbox and grid are out; fixed-width tables are in.&lt;/p&gt;

&lt;p&gt;So the same architecture had to arrive by a different route.&lt;/p&gt;

&lt;p&gt;In the email theme, tokens are resolved at render time in FreeMarker and interpolated straight into &lt;code&gt;style&lt;/code&gt; attributes, with a literal fallback if the property is missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;#local colorPrimary = properties.kcTokenColorPrimary!'#4f46e5'&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The design system is identical — same properties, same names, same child overrides.&lt;/p&gt;

&lt;p&gt;Only the delivery differs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the browser resolves variables at paint time&lt;/li&gt;
&lt;li&gt;FreeMarker resolves them at send time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That let all seventeen emails share a single template shell, with the body copy arriving as pre-formatted strings from the message bundles, and all 36 shipped language translations preserved.&lt;/p&gt;

&lt;p&gt;A child theme rebrands every email it sends by changing the same colour it changed for the login page — because both types import the same &lt;code&gt;common&lt;/code&gt; theme.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping overrides intentional
&lt;/h2&gt;

&lt;p&gt;Inheritance is useful.&lt;/p&gt;

&lt;p&gt;Unlimited overriding is not.&lt;/p&gt;

&lt;p&gt;If every derived theme overrides everything, you have arrived back at duplicated themes with extra indirection.&lt;/p&gt;

&lt;p&gt;So the customization boundary has to stay clear, and I find it easier to state as two questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The base theme answers: &lt;strong&gt;how does authentication look and behave by default?&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The derived theme answers: &lt;strong&gt;how does this product want to brand it?&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Layout, forms, buttons, typography and spacing are the base's responsibility — a child may override them, but doing so should feel like a decision, not a default.&lt;/p&gt;

&lt;p&gt;Primary colour, logo, favicon and product-specific assets are the child's responsibility, and always were.&lt;/p&gt;

&lt;p&gt;Everything in between deserves a conversation before it gets copied.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying this to the repository
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/ismailza/keycloak-modern-auth" rel="noopener noreferrer"&gt;&lt;code&gt;keycloak-modern-auth&lt;/code&gt;&lt;/a&gt; isn't a sample project built to illustrate a finished idea.&lt;/p&gt;

&lt;p&gt;It's the actual thing, evolving:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v0.1   Keycloak setup: Reproducible Keycloak + PostgreSQL stack
  ↓
v0.2   Custom login theme
  ↓
v0.3   Custom email theme
  ↓
next   Derived themes, configuration as code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I keep evolving one repository instead of creating a fresh one per experiment, and that's deliberate.&lt;/p&gt;

&lt;p&gt;Separate repositories make each example easy to read while hiding the thing I actually care about:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineering decisions are incremental. The first implementation is rarely the final architecture.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The honest progression looks more like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"It works."
      ↓
"But there's duplication."
      ↓
"Extract the common parts."
      ↓
"Make the design configurable."
      ↓
"Now a brand is twenty lines and a logo."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's much closer to how a real project moves.&lt;/p&gt;

&lt;p&gt;The repository stops being sample code and becomes a record of the architectural evolution — including the steps that were replaced.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off
&lt;/h2&gt;

&lt;p&gt;A base theme isn't automatically better.&lt;/p&gt;

&lt;p&gt;It's another abstraction, and abstractions charge rent.&lt;/p&gt;

&lt;p&gt;You now have two artifacts where you had one, and anyone touching the themes has to understand the inheritance relationship before they can safely change anything.&lt;/p&gt;

&lt;p&gt;Debugging gets less direct, too: when a style comes from the derived theme it's obvious, and when it comes from the parent — or from a property merged three levels up — you need to know where to look.&lt;/p&gt;

&lt;p&gt;Whole-file template resolution has a sharp edge as well, since a child that overrides &lt;code&gt;template.ftl&lt;/code&gt; to change one line silently stops inheriting every future improvement to the base.&lt;/p&gt;

&lt;p&gt;This is the classic bargain:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You accept some additional complexity in exchange for less duplication and more consistency.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a single small Keycloak installation, that trade isn't obviously worth it.&lt;/p&gt;

&lt;p&gt;For multiple applications or brands, it stops being close.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;The interesting part of this exercise wasn't writing CSS.&lt;/p&gt;

&lt;p&gt;It was noticing the moment customization turned into architecture.&lt;/p&gt;

&lt;p&gt;At the start, the question was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do I customize Keycloak?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Later it became:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do I make this customization reusable?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those are different questions with different answers.&lt;/p&gt;

&lt;p&gt;The first produces a working theme.&lt;/p&gt;

&lt;p&gt;The second produces a system for producing themes.&lt;/p&gt;

&lt;p&gt;The distinction I try to hold onto is this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't introduce an abstraction because it looks elegant. Introduce it when a repeated problem justifies it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here, the repeated theme structure and the need for consistent branding across products supplied the justification.&lt;/p&gt;

&lt;p&gt;Keycloak's inheritance provided the mechanism, and design tokens provided a clean way to make the visual system both reusable and customizable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The base theme is one step, not a destination.&lt;/p&gt;

&lt;p&gt;The directions I want to explore next are mostly about everything around the theme rather than the theme itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more reusable authentication components&lt;/li&gt;
&lt;li&gt;better token organization&lt;/li&gt;
&lt;li&gt;multiple derived brands&lt;/li&gt;
&lt;li&gt;automated validation so a broken theme fails before it reaches an environment&lt;/li&gt;
&lt;li&gt;packaging themes into the image rather than bind-mounting them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Underneath all of that sits a problem I keep running into:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How do you keep Keycloak configuration synchronized across development, staging, qualification and production?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Themes are the visible layer, but realms, clients, roles, mappers and flows are the part that actually drifts.&lt;/p&gt;

&lt;p&gt;That's where theme customization stops being a frontend concern and turns into an infrastructure one — and it's what I want to write about next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;What started as a Keycloak customization is slowly turning into a small authentication platform.&lt;/p&gt;

&lt;p&gt;The lesson isn't that every Keycloak project needs a sophisticated base theme.&lt;/p&gt;

&lt;p&gt;It's that architecture should follow the problems you actually hit.&lt;/p&gt;

&lt;p&gt;Start simple.&lt;/p&gt;

&lt;p&gt;Notice the duplication.&lt;/p&gt;

&lt;p&gt;Learn the extension points the platform already gives you.&lt;/p&gt;

&lt;p&gt;Extract only the parts that are genuinely reusable.&lt;/p&gt;

&lt;p&gt;Then introduce the abstraction.&lt;/p&gt;

&lt;p&gt;The implementation continues to evolve in &lt;a href="https://github.com/ismailza/keycloak-modern-auth" rel="noopener noreferrer"&gt;&lt;code&gt;keycloak-modern-auth&lt;/code&gt;&lt;/a&gt;, alongside this series.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is engineering in practice: not designing the perfect architecture upfront, but improving it as the real problems come into focus.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>keycloak</category>
      <category>architecture</category>
      <category>themes</category>
    </item>
    <item>
      <title>OAuth 2.0, OpenID Connect, and Keycloak: Understanding Modern Authentication</title>
      <dc:creator>Ismail ZAHIR</dc:creator>
      <pubDate>Fri, 07 Aug 2026 14:05:16 +0000</pubDate>
      <link>https://dev.to/ismailzahir/oauth-20-openid-connect-and-keycloak-understanding-modern-authentication-2iej</link>
      <guid>https://dev.to/ismailzahir/oauth-20-openid-connect-and-keycloak-understanding-modern-authentication-2iej</guid>
      <description>&lt;p&gt;OAuth 2.0, OpenID Connect, JWT, access tokens, refresh tokens, Keycloak. These get mentioned in the same breath so often that they blur into a single vague thing called "authentication." They're not one thing. Each solves a distinct problem, and the boundaries between them are sharper than most introductions suggest.&lt;/p&gt;

&lt;p&gt;That blurring has practical consequences. It's why teams send ID tokens to APIs, enforce permissions in the frontend, or reach for a flow that was deprecated years ago — not from carelessness, but because nobody drew the lines clearly in the first place.&lt;/p&gt;

&lt;p&gt;So let's draw them. We'll take each piece in turn, then follow a real login through an Angular app, a Keycloak realm, and a Spring Boot API to see where each one actually does its work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication is not authorization
&lt;/h2&gt;

&lt;p&gt;These two get used interchangeably in conversation, and it causes real bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentication&lt;/strong&gt; answers "who are you?" You type a password, a fingerprint gets scanned, a magic link gets clicked. At the end of it, the system knows your identity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authorization&lt;/strong&gt; answers "what are you allowed to do?" An admin can manage users. A doctor can open a patient record. A patient can only see their own appointments.&lt;/p&gt;

&lt;p&gt;The ordering matters: you can't decide what someone is allowed to do until you know who they are. Every system does authentication first, authorization second, even when the code makes it look like one step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why OAuth 2.0 exists
&lt;/h2&gt;

&lt;p&gt;Say your application needs to read files from a user's Google Drive.&lt;/p&gt;

&lt;p&gt;The naive approach is to ask for their Google password and log in on their behalf. Hopefully the problem is obvious — you now store a credential that unlocks their email, their photos, their entire account, and you've given yourself unlimited access when you only needed to read one folder.&lt;/p&gt;

&lt;p&gt;OAuth 2.0 exists to avoid exactly this. The user authenticates directly with Google. Google asks whether they want to grant your app access to Drive. If they agree, your app receives a token scoped to that specific permission. You never see the password, and the user can revoke your access at any time without changing it.&lt;/p&gt;

&lt;p&gt;So OAuth 2.0 is an &lt;strong&gt;authorization framework&lt;/strong&gt;: it lets an application act on a user's behalf without ever holding their credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four roles
&lt;/h2&gt;

&lt;p&gt;OAuth defines four participants, and it's worth being able to name them because every OAuth error message assumes you can.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resource Owner&lt;/strong&gt; — the user. They own the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client&lt;/strong&gt; — the application asking for access. Your Angular SPA, a mobile app, a background service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization Server&lt;/strong&gt; — authenticates the user, gets their consent, issues tokens. This is Keycloak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource Server&lt;/strong&gt; — the API holding the protected data. Your Spring Boot or ASP.NET Core backend, which validates the token before responding.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What OpenID Connect adds
&lt;/h2&gt;

&lt;p&gt;Here's the part that trips people up: OAuth 2.0 is not an authentication protocol.&lt;/p&gt;

&lt;p&gt;An access token tells an API "the bearer of this token is allowed to do X." It doesn't reliably tell your &lt;em&gt;application&lt;/em&gt; who the user is. Plenty of teams worked around this by calling some vendor-specific "get me the user" endpoint after the OAuth dance, and every vendor did it differently.&lt;/p&gt;

&lt;p&gt;OpenID Connect (OIDC) standardises that. It's a thin identity layer on top of OAuth 2.0, and its main contribution is the &lt;strong&gt;ID Token&lt;/strong&gt; — a JWT describing the authenticated user, with predictable claim names:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sub&lt;/code&gt; — a stable unique identifier for the user&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;given_name&lt;/code&gt;, &lt;code&gt;family_name&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;email&lt;/code&gt;, &lt;code&gt;email_verified&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;preferred_username&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OIDC also standardises the discovery document, the userinfo endpoint, and logout. Because of it, an OIDC client library works against Keycloak, Auth0, Okta, or Azure AD with nothing more than a URL change.&lt;/p&gt;

&lt;p&gt;Short version: &lt;strong&gt;OAuth 2.0 delegates authorization. OpenID Connect adds authentication.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Keycloak fits
&lt;/h2&gt;

&lt;p&gt;Keycloak is an open-source Identity and Access Management server that implements both specs. Instead of writing your own login page, password hashing, session handling, MFA, and social login, you delegate all of it and your apps just trust Keycloak.&lt;/p&gt;

&lt;p&gt;One note before going further: Keycloak has changed a lot. The old Java adapters (&lt;code&gt;keycloak-spring-boot-starter&lt;/code&gt; and friends) were deprecated years ago and have since been removed, so on Keycloak 26+ you secure a Spring Boot API with Spring Security's own OAuth 2.0 Resource Server support and nothing Keycloak-specific on the backend at all. If a tutorial tells you to add a Keycloak adapter dependency, it predates the Quarkus distribution and you can close the tab.&lt;/p&gt;

&lt;p&gt;A few Keycloak-specific concepts you'll meet immediately:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Realms.&lt;/strong&gt; A realm is an isolated tenant — its own users, roles, clients, and signing keys. Users in realm A don't exist in realm B. A common pattern is one realm for your staff and another for your customers, since they rarely share anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clients, public and confidential.&lt;/strong&gt; Every application that talks to Keycloak is registered as a client. A &lt;em&gt;confidential&lt;/em&gt; client can keep a secret (a backend service). A &lt;em&gt;public&lt;/em&gt; client can't — an Angular SPA ships its entire source to the browser, so anything you call a "secret" there is just a string anyone can read in DevTools. SPAs and mobile apps must be public clients, which is precisely why PKCE exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Realm roles vs client roles.&lt;/strong&gt; Realm roles are global (&lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;doctor&lt;/code&gt;). Client roles are scoped to one application (&lt;code&gt;billing-app:invoice-manager&lt;/code&gt;). If two apps in your realm both need a role called &lt;code&gt;manager&lt;/code&gt; meaning different things, that's your signal to use client roles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The discovery endpoint.&lt;/strong&gt; Every realm exposes its configuration at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://&amp;lt;host&amp;gt;/realms/&amp;lt;realm&amp;gt;/.well-known/openid-configuration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open it in a browser. You get every endpoint URL, the supported flows, and the JWKS URI where your backend fetches public keys. Most client libraries need only this one URL. It's also the fastest way to confirm a realm name is spelled the way you think it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Authorization Code Flow with PKCE
&lt;/h2&gt;

&lt;p&gt;For browser and mobile apps, this is the flow to use. The Implicit Flow you may still see in older tutorials is deprecated — don't.&lt;/p&gt;

&lt;p&gt;Let's walk through it with an Angular app in front of a Spring Boot API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — the app notices you're not logged in.&lt;/strong&gt; No valid token in memory, so it prepares to redirect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — PKCE setup, then redirect.&lt;/strong&gt; Before going anywhere, the app generates a random string called the &lt;code&gt;code_verifier&lt;/code&gt;, hashes it with SHA-256, and base64url-encodes the result into a &lt;code&gt;code_challenge&lt;/code&gt;. The verifier stays in the browser. Only the challenge goes to Keycloak, along with a &lt;code&gt;state&lt;/code&gt; parameter (random, and checked on the way back — that's your CSRF protection) and the redirect URI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — you log in.&lt;/strong&gt; Keycloak renders its login page, checks your credentials, runs MFA if configured, and redirects back to your app with a short-lived &lt;code&gt;authorization code&lt;/code&gt; in the URL. Worth emphasising: this is &lt;em&gt;not&lt;/em&gt; a token. On its own it's useless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4 — the exchange.&lt;/strong&gt; The app POSTs the code back to Keycloak's token endpoint, this time including the original &lt;code&gt;code_verifier&lt;/code&gt;. Keycloak hashes it and compares against the challenge from step 2. Match, and you get an access token, an ID token, and a refresh token.&lt;/p&gt;

&lt;p&gt;This is the whole point of PKCE. Authorization codes travel through the browser's address bar, and on mobile through OS-level URL handlers — both interceptable. Without PKCE, an attacker who steals the code can redeem it themselves. With PKCE they'd also need the verifier, which never left the original app. It's a proof that whoever redeems the code is whoever started the flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5 — calling the API.&lt;/strong&gt; The app attaches the access token to requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 6 — the API validates.&lt;/strong&gt; Spring Boot checks the token's signature, issuer, audience, and expiry, then serves the data — or returns &lt;code&gt;401&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the whole exchange in one picture:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzpbv6pkswsemkvic4ph0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzpbv6pkswsemkvic4ph0.png" alt="The Authorization Code Flow with PKCE, end to end" width="800" height="648"&gt;&lt;/a&gt;&lt;br&gt;
The Authorization Code Flow with PKCE, end to end&lt;/p&gt;
&lt;h2&gt;
  
  
  The three tokens
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Access token.&lt;/strong&gt; Presented to APIs. Contains the subject, roles, scopes, and an expiry. Keycloak defaults to a &lt;strong&gt;5-minute&lt;/strong&gt; lifespan, and that's a reasonable default — a leaked token stops being useful quickly.&lt;/p&gt;

&lt;p&gt;A word on &lt;strong&gt;scopes&lt;/strong&gt;, since they're easy to confuse with roles. A scope is what the &lt;em&gt;application&lt;/em&gt; asked permission to do; a role is what the &lt;em&gt;user&lt;/em&gt; is allowed to do. When your Angular app requests &lt;code&gt;openid profile email&lt;/code&gt;, it's saying "I want an ID token, plus the user's profile and email claims" — and the user's consent screen reflects that. In Keycloak these are configured as client scopes, which control both the claims that land in the token and the roles included in it. The distinction matters because a token can carry a powerful role and still be scoped too narrowly for what your API wants to do with it — the API should check both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ID token.&lt;/strong&gt; For the client application only, to answer "who is logged in?" Use it to display a name and avatar. Do not send it to your API as a credential; it isn't scoped for that and a correctly configured API will reject it anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refresh token.&lt;/strong&gt; Longer-lived, used to get a new access token without sending the user back through login. Turn on &lt;strong&gt;refresh token rotation&lt;/strong&gt; — each refresh invalidates the previous token. If a rotated token gets replayed, Keycloak sees a token that's already been used and can kill the whole session. For public clients this isn't optional in my view; it's the only real protection a token you can't keep secret has.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's actually inside an access token
&lt;/h2&gt;

&lt;p&gt;All of this stays abstract until you decode one. A Keycloak access token is a JWT: three base64url segments separated by dots, &lt;code&gt;header.payload.signature&lt;/code&gt;. Paste one into &lt;a href="https://jwt.io/" rel="noopener noreferrer"&gt;jwt.io&lt;/a&gt; and you get something like this.&lt;/p&gt;

&lt;p&gt;The header names the algorithm and, importantly, the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"RS256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"typ"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JWT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sBv3qHhL2XmR7pKdN9fYcW1uEjTgA4Zo"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1754563200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1754562900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jti"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3f1c7a92-8d4e-4b16-9c05-7ae2f0b8d331"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://auth.example.com/realms/healthcare"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"billing-api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"account"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9b2d41f7-6c8a-4e3b-bf19-2d05c7e4a8f1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"typ"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"azp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"web-app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"c4a8e102-5f37-49bd-8e6a-1b93d70cf254"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openid profile email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"realm_access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"doctor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"offline_access"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resource_access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"billing-api"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"invoice-manager"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"preferred_username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ismail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ismail@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email_verified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything discussed so far is visible here. &lt;code&gt;iss&lt;/code&gt; is the realm. &lt;code&gt;aud&lt;/code&gt; lists who this token is for. &lt;code&gt;sub&lt;/code&gt; is the stable user ID — note that it's a UUID, not the username, which is why you should key your own database on &lt;code&gt;sub&lt;/code&gt; and never on &lt;code&gt;preferred_username&lt;/code&gt; or &lt;code&gt;email&lt;/code&gt; (both can change). &lt;code&gt;realm_access.roles&lt;/code&gt; and &lt;code&gt;resource_access&lt;/code&gt; are the realm and client roles from earlier, arriving in exactly the shape Keycloak defines. &lt;code&gt;azp&lt;/code&gt; is the client that requested the token.&lt;/p&gt;

&lt;p&gt;One thing worth internalising: this payload is &lt;strong&gt;base64-encoded, not encrypted&lt;/strong&gt;. Anyone holding the token can read every claim in it. That's fine for roles and usernames. It is not fine for anything you'd call confidential, so resist the temptation to stuff internal identifiers or business data into custom claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where do you store tokens?
&lt;/h2&gt;

&lt;p&gt;Every tutorial says "store them securely" and moves on. That phrase hides the single most argued-about decision in frontend auth, so let's be honest about the options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;localStorage&lt;/strong&gt; is the easy path and survives page refreshes. It's also readable by any JavaScript on the page, which means one XSS bug — yours or a compromised npm dependency's — hands over every token you hold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In-memory only&lt;/strong&gt; removes the XSS smash-and-grab, since there's nothing persisted to steal. The cost is that a refresh logs the user out, unless you use silent renewal via a hidden iframe, which third-party cookie restrictions are steadily breaking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend-for-Frontend (BFF)&lt;/strong&gt; keeps tokens on a small server-side layer and gives the browser only an &lt;code&gt;httpOnly&lt;/code&gt; cookie. The browser never touches a token. This is the most secure option and it's where the industry is heading — but it's real infrastructure, and it brings CSRF back into scope.&lt;/p&gt;

&lt;p&gt;There's no universally correct answer. Pick deliberately based on what your app protects, and know what you're trading away. Medical records and a marketing dashboard don't warrant the same call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating tokens on the backend
&lt;/h2&gt;

&lt;p&gt;"Validate the token" also deserves unpacking, because getting it partly right is a genuine vulnerability. Your API must check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Signature&lt;/strong&gt; — verified against Keycloak's public keys from the JWKS endpoint. Libraries fetch and cache these automatically; make sure yours actually does.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;iss&lt;/code&gt; — the issuer matches your realm URL exactly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aud&lt;/code&gt; — the audience includes &lt;em&gt;your&lt;/em&gt; API. This is the one people skip. Without it, a token issued for a different client in the same realm will sail straight through your validation, because the signature is perfectly valid. It just wasn't meant for you.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;exp&lt;/code&gt; — not expired.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any decent OIDC library does all four once configured. The failure mode is almost always a missing or wrong &lt;code&gt;aud&lt;/code&gt;, so check that first when something feels off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key rotation, and why you shouldn't hardcode a public key
&lt;/h2&gt;

&lt;p&gt;Keycloak rotates its realm signing keys — on a schedule, or whenever you rotate them manually after an incident. This is exactly what the &lt;code&gt;kid&lt;/code&gt; in the token header is for: it identifies which key signed this particular token, so old tokens stay verifiable while new ones are signed with the new key.&lt;/p&gt;

&lt;p&gt;Your API should therefore fetch keys from the JWKS endpoint and cache them, refetching when it sees a &lt;code&gt;kid&lt;/code&gt; it doesn't recognise. Spring Security does this out of the box when you point it at the issuer URI. What you must not do is copy a PEM public key out of the admin console and paste it into &lt;code&gt;application.yml&lt;/code&gt; — I've seen it, it works fine for months, and then every request in production returns &lt;code&gt;401&lt;/code&gt; the morning after a key rotation with no obvious cause. Configure the issuer, let the library handle the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things people get wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"OAuth 2.0 is authentication."&lt;/strong&gt; It isn't. It's an authorization framework. Authentication comes from OpenID Connect layered on top.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"JWT and OAuth are the same thing."&lt;/strong&gt; OAuth defines how authorization is delegated. JWT is one possible format for the tokens involved. OAuth works fine with opaque tokens, and plenty of providers use them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"The frontend decides permissions."&lt;/strong&gt; The frontend hides buttons the user can't use. That's UX, not security — anyone can open DevTools and call your API directly. The backend enforces authorization, always, no exceptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Long-lived access tokens improve the user experience."&lt;/strong&gt; They improve it right up until one leaks and stays valid for a week. Short access tokens plus rotating refresh tokens plus automatic renewal gives you the same seamless experience without the exposure window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Authorization Code Flow with PKCE for browser and mobile apps. Never Implicit.&lt;/li&gt;
&lt;li&gt;Access token lifespan in minutes, not hours.&lt;/li&gt;
&lt;li&gt;Refresh token rotation on for public clients.&lt;/li&gt;
&lt;li&gt;Validate signature, &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;aud&lt;/code&gt;, and &lt;code&gt;exp&lt;/code&gt; on every API request.&lt;/li&gt;
&lt;li&gt;Realm roles for cross-application concepts, client roles for app-specific ones.&lt;/li&gt;
&lt;li&gt;HTTPS everywhere in production — a bearer token over plain HTTP is a password over plain HTTP.&lt;/li&gt;
&lt;li&gt;Don't build your own auth server. Really.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Most of the confusion around this stack disappears once each piece has one clear job:&lt;/p&gt;

&lt;p&gt;OAuth 2.0 delegates authorization. OpenID Connect adds authentication and a standard identity format. Keycloak implements both and manages the users. Access tokens authorize API calls, ID tokens identify the user to the app, and refresh tokens keep the session alive without repeated logins.&lt;/p&gt;

&lt;p&gt;Once that clicks, Keycloak's configuration screens stop being a maze and start being a map of the protocol.&lt;/p&gt;

&lt;p&gt;Next in this series: making Keycloak stop looking like Keycloak. We'll build a custom login theme from scratch — FreeMarker templates, CSS custom properties for multi-brand support, and full RTL for Arabic, which is where things get genuinely interesting.&lt;/p&gt;

&lt;p&gt;— Ismail&lt;/p&gt;

</description>
      <category>authentication</category>
      <category>openidconnect</category>
      <category>keycloak</category>
    </item>
    <item>
      <title>Why I Validate Angular Compatibility Using the Published npm Package (Not the Source Code)</title>
      <dc:creator>Ismail ZAHIR</dc:creator>
      <pubDate>Fri, 31 Jul 2026 00:52:23 +0000</pubDate>
      <link>https://dev.to/ismailzahir/why-i-validate-angular-compatibility-using-the-published-npm-package-not-the-source-code-808</link>
      <guid>https://dev.to/ismailzahir/why-i-validate-angular-compatibility-using-the-published-npm-package-not-the-source-code-808</guid>
      <description>&lt;p&gt;I just published a new article on a lesson I learned while maintaining my open-source Angular library.&lt;/p&gt;

&lt;p&gt;I used to validate compatibility against my workspace and &lt;code&gt;dist/&lt;/code&gt;, but eventually realized that wasn't what developers actually install from npm.&lt;/p&gt;

&lt;p&gt;That led me to redesign the CI pipeline to validate the &lt;strong&gt;published package&lt;/strong&gt; across multiple Angular versions instead.&lt;/p&gt;

&lt;p&gt;The article covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why testing &lt;code&gt;dist/&lt;/code&gt; isn't enough&lt;/li&gt;
&lt;li&gt;Angular partial compilation and the linker&lt;/li&gt;
&lt;li&gt;Why type-checking alone can miss compatibility issues&lt;/li&gt;
&lt;li&gt;Validating the packaged artifact with &lt;code&gt;npm pack&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Building a compatibility matrix for Angular 17–22&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you maintain an Angular library (or any npm package), I'd love to hear how you approach compatibility testing.&lt;/p&gt;

&lt;p&gt;📖 &lt;em&gt;Why I Validate Angular Compatibility Using the Published npm Package (Not the Source Code)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://medium.com/@ismailzahir/why-i-validate-angular-compatibility-using-the-published-npm-package-not-the-source-code-32421e090284?sharedUserId=ismailzahir" rel="noopener noreferrer"&gt;Medium link&lt;/a&gt;&lt;/p&gt;

</description>
      <category>angular</category>
      <category>cicd</category>
      <category>opensource</category>
      <category>testing</category>
    </item>
    <item>
      <title>I Stopped Copy-Pasting the Same Angular ApiService. Here’s What I Built Instead</title>
      <dc:creator>Ismail ZAHIR</dc:creator>
      <pubDate>Sun, 26 Jul 2026 12:16:23 +0000</pubDate>
      <link>https://dev.to/ismailzahir/i-stopped-copy-pasting-the-same-angular-apiservice-heres-what-i-built-instead-1o3f</link>
      <guid>https://dev.to/ismailzahir/i-stopped-copy-pasting-the-same-angular-apiservice-heres-what-i-built-instead-1o3f</guid>
      <description>&lt;p&gt;Every Angular project I've worked on had an &lt;code&gt;api.service.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Different repository.&lt;/p&gt;

&lt;p&gt;Different company.&lt;/p&gt;

&lt;p&gt;Same file.&lt;/p&gt;

&lt;p&gt;It always started as a thin wrapper around &lt;code&gt;HttpClient&lt;/code&gt;, then gradually accumulated more responsibility: versioning, authentication, retry logic, loading indicators, error handling.&lt;/p&gt;

&lt;p&gt;By the fourth project, I wasn't writing it anymore.&lt;/p&gt;

&lt;p&gt;I was copying it.&lt;/p&gt;

&lt;p&gt;And not carefully. I'd paste the file in, then spend the next hour re-fixing the same three bugs I'd already fixed in the last project — because I'd never written them down anywhere, just patched them in place and moved on.&lt;/p&gt;

&lt;p&gt;That was the moment I realized it wasn't repeated code anymore.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A pattern repeated four times isn't a pattern anymore. It's a missing dependency.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I extracted it, made the hard-coded parts configurable, and published it as &lt;code&gt;@ismailza/ngx-api-client&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This article isn't really about the library. It's about the four decisions that turned a file I was slightly embarrassed to copy-paste into something I'd defend in a code review.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual gap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;HttpClient&lt;/code&gt; is a good HTTP client. That's genuinely all it claims to be, and people keep being disappointed that it isn't more.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;HttpClient&lt;/code&gt; gives you a request. It does not give you a policy.&lt;/p&gt;

&lt;p&gt;Every application still has to answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Where does the base URL come from, and how does the API version get into it?&lt;/li&gt;
&lt;li&gt;What turns a failed response into something a component can actually render?&lt;/li&gt;
&lt;li&gt;Which requests are safe to retry, and how long do you wait before you do?&lt;/li&gt;
&lt;li&gt;How does a global progress bar find out that anything is in flight?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most applications answer those questions inside an &lt;code&gt;ApiService&lt;/code&gt; that grows organically over time until nobody wants to touch it anymore. I wanted those decisions to live in one place — and to see, at a glance, how a request actually moves through the system:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl55i6zfvess3wfftdpnw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl55i6zfvess3wfftdpnw.png" alt="How a request actually moves through ngx-api-client&lt;br&gt;
" width="800" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A request leaves the component, passes through ApiService, then through an ordered chain — your auth interceptor, retry with jitter, your backend, error normalization, an optional success step — before looping back. Loading state and error handling hang off that chain as pluggable pieces, not built-in opinions.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The first mistake I kept repeating was hard-coding the version
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/v1&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It works...&lt;/p&gt;

&lt;p&gt;...until your backend decides to version using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;headers&lt;/li&gt;
&lt;li&gt;query parameters&lt;/li&gt;
&lt;li&gt;media types&lt;/li&gt;
&lt;li&gt;dates&lt;/li&gt;
&lt;li&gt;or no URL versioning at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of treating versioning as string concatenation, I modeled it as a configurable strategy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;provideApi&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;versioning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same configuration can instead produce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/api/v2/orders&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/orders?v=2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;X-API-Version: 2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Accept: application/vnd.api.v2+json&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;without changing application code.&lt;/p&gt;

&lt;p&gt;Two rules became surprisingly important:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A header or query parameter the caller set explicitly is never overwritten by the versioning strategy.&lt;/strong&gt; If you pass &lt;code&gt;Api-Version&lt;/code&gt; yourself, you meant it. A library that silently clobbers explicit caller input is a library you cannot debug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Versions should accept strings as well as numbers.&lt;/strong&gt; This only became obvious after integrating with a backend that versioned by date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;provideApi&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2024-01-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;versioning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headerName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Api-Version&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I had typed it as &lt;code&gt;number&lt;/code&gt; — which is what my copy-pasted version implicitly assumed — the whole abstraction would have been useless for the exact API that motivated making it configurable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Then I realized my biggest problem wasn't requests — it was failures
&lt;/h2&gt;

&lt;p&gt;HTTP failures rarely look the same.&lt;/p&gt;

&lt;p&gt;Sometimes you get a proper RFC 9457 Problem Details response. Well-behaved, easy.&lt;/p&gt;

&lt;p&gt;Sometimes nginx sends back plain text, because it answered before your app ever saw the request.&lt;/p&gt;

&lt;p&gt;Sometimes the network disappears completely and the status is simply &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Components shouldn't need three different code paths for those three situations. Everything gets normalized into one shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ApiError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// RFC 9457 problem type URI&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 'Bad Request'&lt;/span&gt;
  &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 0 for a network failure&lt;/span&gt;
  &lt;span class="nl"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// safe to show the user&lt;/span&gt;
  &lt;span class="nl"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// path that produced it&lt;/span&gt;
  &lt;span class="nl"&gt;code&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// machine-readable, e.g. 'VALIDATION_ERROR'&lt;/span&gt;
  &lt;span class="nl"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;traceId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// from the body, else the X-Trace-Id header&lt;/span&gt;
  &lt;span class="nl"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once a component receives this, it no longer cares where the failure originated.&lt;/p&gt;

&lt;p&gt;Two things worth stating out loud:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Branch on &lt;code&gt;code&lt;/code&gt;, never on &lt;code&gt;detail&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;detail&lt;/code&gt; is prose meant for a human. It gets reworded, translated, and A/B tested. The day somebody changes "Invalid credentials" to "Incorrect email or password," every &lt;code&gt;if (error.detail === "Invalid credentials")&lt;/code&gt; in your codebase silently stops working.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INVALID_CREDENTIALS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;code&lt;/code&gt; is the contract. &lt;code&gt;detail&lt;/code&gt; is for humans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;traceId&lt;/code&gt; falls back to the &lt;code&gt;X-Trace-Id&lt;/code&gt; header.&lt;/strong&gt; Nine times out of ten the useful correlation ID is in the response headers and the error body is empty. If your error model discards headers, every production bug report starts with "can you reproduce it?"&lt;/p&gt;




&lt;h2&gt;
  
  
  I almost shipped a toast notification. I'm glad I didn't
&lt;/h2&gt;

&lt;p&gt;That was the easiest feature to add, and the hardest to leave out on purpose.&lt;/p&gt;

&lt;p&gt;The moment a library renders anything, it has an opinion about your design system, your i18n setup, and your accessibility strategy — three things it cannot possibly know. So the library only exposes an error handler that applications replace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ToastApiErrorHandler&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;ApiErrorHandler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;toast&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MyToastService&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;override&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ApiError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;navigate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/forbidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTP layer reports problems. The application decides how users experience them.&lt;/p&gt;

&lt;p&gt;The practical payoff: peer dependencies are &lt;code&gt;@angular/core&lt;/code&gt;, &lt;code&gt;@angular/common&lt;/code&gt;, and &lt;code&gt;rxjs&lt;/code&gt;. Nothing else. A library sitting in your HTTP layer has no business pulling a UI kit into your bundle.&lt;/p&gt;

&lt;p&gt;One honest caveat I put in the README rather than hiding: the default handler passes the whole &lt;code&gt;ApiError&lt;/code&gt; to Angular's own &lt;code&gt;ErrorHandler&lt;/code&gt;, which logs it. If your API puts sensitive data in &lt;code&gt;detail&lt;/code&gt; or &lt;code&gt;instance&lt;/code&gt;, register your own handler instead of relying on the default. Defaults should be safe, and where they can't be, they should say so.&lt;/p&gt;




&lt;h2&gt;
  
  
  One decision surprised every reviewer — I don't register my own interceptors
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;provideHttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;withInterceptors&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;retryInterceptor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;apiErrorInterceptor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;apiSuccessInterceptor&lt;/span&gt;
  &lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the one I had to defend hardest in review. At first glance it looks like boilerplate the library should absorb. It isn't, because order matters and only you know what else is in the chain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An auth interceptor has to come first, so a retried request picks up a fresh token instead of replaying the expired one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retryInterceptor&lt;/code&gt; has to come before &lt;code&gt;apiErrorInterceptor&lt;/code&gt;, so your error handler only ever sees failures that survived every retry. Get this backwards and users see three toasts for one eventually-successful request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the library silently injected interceptors at a position it chose, that position would be wrong in some app, and debugging it would mean reading my source. Making the ordering explicit costs one line and buys the ability to reason about the chain.&lt;/p&gt;




&lt;h2&gt;
  
  
  Retry is harder than it looks
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's five characters and it isn't enough. A few rules that all turned out to matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don't retry &lt;code&gt;POST&lt;/code&gt; requests by default. It isn't idempotent — replaying it can create two orders. Callers opt in per request when they know their endpoint is safe.&lt;/li&gt;
&lt;li&gt;Respect the server's &lt;code&gt;Retry-After&lt;/code&gt; header. On a 429, the server has told you exactly when to come back; your backoff curve is a guess, the header isn't.&lt;/li&gt;
&lt;li&gt;Retry only transient failures: &lt;code&gt;408, 429, 500, 502, 503, 504&lt;/code&gt;. Retrying a &lt;code&gt;400&lt;/code&gt; is just asking the server to reject you four times.&lt;/li&gt;
&lt;li&gt;Use exponential backoff — and always add jitter.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exponentialDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initialDelay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;multiplier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;retryIndex&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;exponentialDelay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exponentialDelay&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;jitter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without jitter, every client that hit your struggling backend at the same moment retries at the same moment, and again two seconds later, and again four seconds after that. You've built a synchronized load test against a server that's already failing. A little randomness breaks the lockstep.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two smaller decisions I'm glad I made
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Per-request configuration travels through &lt;code&gt;HttpContext&lt;/code&gt;, not service state.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;LegacyOrder&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/analytics/event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;skipErrorHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;showLoader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One request can disable retries, skip global error handling, or bypass loading indicators without touching any concurrent request. A mutable flag on the service that the next interceptor reads would be a race condition waiting for two calls to happen at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loading state is a counter, not a boolean.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;providedIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;root&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiLoadingService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;activeRequests&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activeRequests&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A boolean flickers: three requests start, the fastest one finishes, and the progress bar disappears while two are still running. A counter with a &lt;code&gt;computed()&lt;/code&gt; signal doesn't. It's a five-line class, and it's the single most-copied snippet from every version of this file I ever wrote.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I would tell myself before starting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't extract after the first implementation. Extract after the fourth.&lt;/strong&gt; Three implementations taught me which parts actually varied — the version transport, the error presentation — and which parts never did. Abstracting after one project would have produced configuration options nobody needed, and hard-coded the things that mattered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the README before the code you're unsure about.&lt;/strong&gt; Every section I struggled to explain was a section where the API was wrong. The interceptor-ordering paragraph took three rewrites, and the third is what convinced me not to auto-register them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be honest about compatibility.&lt;/strong&gt; Mine says: developed and tested against Angular 21; the declared floor of &lt;code&gt;&amp;gt;=17&lt;/code&gt; reflects the APIs used — signals and functional interceptors — rather than a range the CI matrix currently covers. Less impressive than claiming full 17–21 support. Also true, which matters more.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;Open source isn't about writing perfect software. It's about taking something you've solved the same way four times, pulling out the pattern, and inviting other people to tell you where it's wrong.&lt;/p&gt;

&lt;p&gt;I'm not sure this library is done evolving. But the ideas behind it — versioning as a strategy instead of string concatenation, one error shape no matter what the backend sends back, keeping presentation out of the HTTP layer, and making interceptor order something you choose rather than something the library hides from you — are decisions I'd make again.&lt;/p&gt;

&lt;p&gt;I'm curious how other Angular teams handle this. Do you build your own API layer, or lean directly on &lt;code&gt;HttpClient&lt;/code&gt;? Which of these decisions would you have made differently?&lt;/p&gt;

&lt;p&gt;This is my first technical article, and I'd genuinely love feedback. If you think one of these design decisions is wrong — or you've solved the problem differently — I'd enjoy hearing your perspective.&lt;/p&gt;

&lt;p&gt;The library is on npm as &lt;code&gt;@ismailza/ngx-api-client&lt;/code&gt;, and the source is on GitHub if you'd like to explore the implementation.&lt;/p&gt;

&lt;p&gt;— Ismail&lt;/p&gt;

</description>
      <category>angular</category>
      <category>typescript</category>
      <category>software</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
