<?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: Artur Havrylov</title>
    <description>The latest articles on DEV Community by Artur Havrylov (@artur-havrylov).</description>
    <link>https://dev.to/artur-havrylov</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1161290%2F384007e7-61b3-4457-90d5-359401f17bc9.jpeg</url>
      <title>DEV Community: Artur Havrylov</title>
      <link>https://dev.to/artur-havrylov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/artur-havrylov"/>
    <language>en</language>
    <item>
      <title>Per-PR Preview Environments with FluxCD</title>
      <dc:creator>Artur Havrylov</dc:creator>
      <pubDate>Fri, 29 May 2026 00:24:59 +0000</pubDate>
      <link>https://dev.to/artur-havrylov/per-pr-preview-environments-with-fluxcd-bpo</link>
      <guid>https://dev.to/artur-havrylov/per-pr-preview-environments-with-fluxcd-bpo</guid>
      <description>&lt;p&gt;You want a preview environment for every pull request - open a PR, get a working URL. On Kubernetes with Flux, there's no built-in way to get one: &lt;code&gt;Kustomization&lt;/code&gt; and &lt;code&gt;HelmRelease&lt;/code&gt; don't template themselves per PR.&lt;/p&gt;

&lt;p&gt;The usual workarounds each have a catch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;kubectl apply&lt;/code&gt; CI job - breaks GitOps, since the cluster drifts from git&lt;/li&gt;
&lt;li&gt;Argo CD &lt;code&gt;ApplicationSet&lt;/code&gt; - now you're running two GitOps controllers&lt;/li&gt;
&lt;li&gt;a custom controller that watches your PRs - works, but it's yours to maintain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a lighter option: the &lt;strong&gt;Flux Operator's &lt;code&gt;ResourceSet&lt;/code&gt; and &lt;code&gt;ResourceSetInputProvider&lt;/code&gt; CRDs&lt;/strong&gt;. Two YAML files per service, no custom code, no second control plane. This post is the full setup - copy-paste-able - using a backend service called &lt;code&gt;api&lt;/code&gt; as the example.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll build
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A preview environment for every open PR, on its own URL (&lt;code&gt;api-1234.preview.example.com&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Deploy gated on the image build - no &lt;code&gt;ImagePullBackOff&lt;/code&gt; while CI is still running.&lt;/li&gt;
&lt;li&gt;Automatic teardown when the PR closes - no cleanup job.&lt;/li&gt;
&lt;li&gt;All in plain YAML, reconciled by Flux.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Three pieces make it work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A watcher&lt;/strong&gt; lists the PRs that are ready to deploy - it keys off a label your CI adds once the image is built.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A templater&lt;/strong&gt; stamps out a full copy of your app for each PR: its own name, URL, and image tag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flux&lt;/strong&gt; deploys those copies and keeps the cluster in sync as PRs open and close.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PR opened + labeled "build/image-ready"
        |
        v
Watcher  (ResourceSetInputProvider, polls every 5m)
  lists each open PR as { id, sha }
        |
        v
Templater  (ResourceSet)
  stamps out one environment per PR
        |
        v
Deployed preview env  (api-1234.preview.example.com)
  auto-deleted when the PR closes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of this post is just wiring those three pieces in YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A cluster already running Flux (&lt;code&gt;flux-system&lt;/code&gt; &lt;code&gt;GitRepository&lt;/code&gt; + controllers).&lt;/li&gt;
&lt;li&gt;The Flux Operator installed on top - it's a separate project from Flux core:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  helm &lt;span class="nb"&gt;install &lt;/span&gt;flux-operator oci://ghcr.io/controlplaneio-fluxcd/charts/flux-operator &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--namespace&lt;/span&gt; flux-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Provider auth: a PAT (code-read scope) via &lt;code&gt;secretRef&lt;/code&gt; - seal it so it lives in git - or workload identity (&lt;code&gt;serviceAccountName&lt;/code&gt; + cloud IAM) to avoid storing a token.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two CRDs
&lt;/h2&gt;

&lt;p&gt;The watcher and templater are two CRDs from the Flux Operator:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ResourceSetInputProvider&lt;/code&gt;&lt;/strong&gt; - the watcher. Polls a source (Azure DevOps, GitHub, GitLab) on an interval and emits one input per open PR, each with fields like &lt;code&gt;inputs.id&lt;/code&gt; (PR number) and &lt;code&gt;inputs.sha&lt;/code&gt; (head commit).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ResourceSet&lt;/code&gt;&lt;/strong&gt; - the templater. Takes those inputs and renders a set of Kubernetes resources once per input, via &lt;code&gt;&amp;lt;&amp;lt; inputs.id &amp;gt;&amp;gt;&lt;/code&gt; substitution.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the watcher and the template are separate, you can swap an Azure DevOps source for a GitHub one without touching what gets deployed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Watch PRs
&lt;/h2&gt;

&lt;p&gt;The input provider for the &lt;code&gt;api&lt;/code&gt; service - one repo, polled every five minutes:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fluxcd.controlplane.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ResourceSetInputProvider&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-prs&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-dev&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;fluxcd.controlplane.io/reconcileEvery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5m"&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AzureDevOpsPullRequest&lt;/span&gt;
  &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://dev.azure.com/{org}/{project}/_git/api&lt;/span&gt;
  &lt;span class="na"&gt;secretRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azure-devops-auth&lt;/span&gt;
  &lt;span class="na"&gt;skip&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;labels&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;!build/image-ready"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;type: AzureDevOpsPullRequest&lt;/code&gt; queries active PRs. &lt;code&gt;GitHubPullRequest&lt;/code&gt; / &lt;code&gt;GitLabMergeRequest&lt;/code&gt; work the same way, so nothing else in the setup changes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;skip.labels: ["!build/image-ready"]&lt;/code&gt; is the key line. The leading &lt;code&gt;!&lt;/code&gt; inverts the match: &lt;em&gt;skip any PR that does NOT have &lt;code&gt;build/image-ready&lt;/code&gt;&lt;/em&gt;. Net effect - only labeled PRs become inputs.&lt;/li&gt;
&lt;li&gt;That label is the &lt;strong&gt;deploy-gates-on-build&lt;/strong&gt; trick. Your CI sets &lt;code&gt;build/image-ready&lt;/code&gt; only after it has built and pushed the image. Until then the provider doesn't see the PR, so the deploy can't race the build. No webhooks, no retry loops, no &lt;code&gt;ImagePullBackOff&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: Template the Kustomization
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;ResourceSet&lt;/code&gt; consumes those inputs and renders one &lt;code&gt;Kustomization&lt;/code&gt; per PR:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fluxcd.controlplane.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ResourceSet&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-pr-envs&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-dev&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;inputsFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fluxcd.controlplane.io/v1&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ResourceSetInputProvider&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-prs&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize.toolkit.fluxcd.io/v1&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kustomization&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-pr-&amp;lt;&amp;lt; inputs.id &amp;gt;&amp;gt;&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-dev&lt;/span&gt;
      &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;dependsOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-common-dev&lt;/span&gt;      &lt;span class="c1"&gt;# shared namespace, ingress class, configmaps&lt;/span&gt;
            &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flux-system&lt;/span&gt;
        &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
        &lt;span class="na"&gt;retryInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
        &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;                   &lt;span class="c1"&gt;# cascade-delete on teardown&lt;/span&gt;
        &lt;span class="na"&gt;wait&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;sourceRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GitRepository&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flux-system&lt;/span&gt;
          &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flux-system&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./clusters/eks/apps/api/pr-template&lt;/span&gt;
        &lt;span class="na"&gt;postBuild&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;substitute&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;PR_NUMBER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;&amp;lt; inputs.id | quote &amp;gt;&amp;gt;&lt;/span&gt;
            &lt;span class="na"&gt;COMMIT_SHA&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;inputs.sha&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If 7 PRs are open, this renders 7 &lt;code&gt;Kustomization&lt;/code&gt; objects: &lt;code&gt;api-pr-1234&lt;/code&gt;, &lt;code&gt;api-pr-1289&lt;/code&gt;, and so on - each one you can inspect, retry, or delete on its own. &lt;code&gt;postBuild.substitute&lt;/code&gt; passes &lt;code&gt;PR_NUMBER&lt;/code&gt; and &lt;code&gt;COMMIT_SHA&lt;/code&gt; down into the manifests at the templated &lt;code&gt;path&lt;/code&gt;. &lt;code&gt;dependsOn&lt;/code&gt; makes each per-PR env wait for shared infra so they don't race during cluster bootstrap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The per-PR overlay
&lt;/h2&gt;

&lt;p&gt;This is the piece most write-ups skip. The &lt;code&gt;path&lt;/code&gt; above points at a Kustomize overlay that turns one base manifest set into a PR-scoped copy:&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="c1"&gt;# ./clusters/eks/apps/api/pr-template/kustomization.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize.config.k8s.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kustomization&lt;/span&gt;
&lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-dev&lt;/span&gt;

&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;../../../../base/api&lt;/span&gt;          &lt;span class="c1"&gt;# the normal, un-PR'd manifests&lt;/span&gt;

&lt;span class="na"&gt;nameSuffix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;-${PR_NUMBER}&lt;/span&gt;         &lt;span class="c1"&gt;# api -&amp;gt; api-1234, on EVERY resource&lt;/span&gt;
&lt;span class="na"&gt;commonLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pr-env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pr-${PR_NUMBER}"&lt;/span&gt;       &lt;span class="c1"&gt;# one label to find/watch the whole env&lt;/span&gt;

&lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.example.com/api&lt;/span&gt;
    &lt;span class="na"&gt;newTag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pr-${PR_NUMBER}&lt;/span&gt;       &lt;span class="c1"&gt;# pull the image CI built for this PR&lt;/span&gt;

&lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-deployment&lt;/span&gt;
    &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;                      &lt;span class="c1"&gt;# previews don't need HA&lt;/span&gt;

&lt;span class="na"&gt;patches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pr-ingress-patch.yaml&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pr-env-vars-patch.yaml&lt;/span&gt;
  &lt;span class="c1"&gt;# cluster-scoped resources can't be duplicated per PR - strip them&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ClusterRole&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;patch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;$patch: delete&lt;/span&gt;
      &lt;span class="s"&gt;apiVersion: rbac.authorization.k8s.io/v1&lt;/span&gt;
      &lt;span class="s"&gt;kind: ClusterRole&lt;/span&gt;
      &lt;span class="s"&gt;metadata: { name: unused }&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ClusterRoleBinding&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;patch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;$patch: delete&lt;/span&gt;
      &lt;span class="s"&gt;apiVersion: rbac.authorization.k8s.io/v1&lt;/span&gt;
      &lt;span class="s"&gt;kind: ClusterRoleBinding&lt;/span&gt;
      &lt;span class="s"&gt;metadata: { name: unused }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanics that make this work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;nameSuffix: -${PR_NUMBER}&lt;/code&gt;&lt;/strong&gt; is the isolation engine. Kustomize appends it to &lt;em&gt;every&lt;/em&gt; resource name, so &lt;code&gt;api&lt;/code&gt; becomes &lt;code&gt;api-1234&lt;/code&gt; across Deployment, Service, Ingress - no per-resource editing. Everything lives in one shared namespace; the suffix keeps PRs from colliding. (Simpler than namespace-per-PR, and you don't pay namespace setup cost on every env.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;images.newTag&lt;/code&gt;&lt;/strong&gt; sets the image tag the Kustomize way instead of string-replacing inside the manifest - cleaner, and Kustomize validates it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;$patch: delete&lt;/code&gt; on cluster-scoped kinds&lt;/strong&gt; is the gotcha nobody warns you about: &lt;code&gt;ClusterRole&lt;/code&gt;/&lt;code&gt;ClusterRoleBinding&lt;/code&gt;/&lt;code&gt;ServiceAccount&lt;/code&gt; are cluster-scoped, so a name suffix would either collide or leak. Strip them from the overlay and provision them once in shared infra instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two patches are small. Ingress gives the PR its own hostname:&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="c1"&gt;# pr-ingress-patch.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-ingress&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-${PR_NUMBER}.preview.example.com&lt;/span&gt;
      &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&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="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
            &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
            &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
                &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;80&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;api-$&lt;/span&gt;&lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;PR_NUMBER&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;&lt;span class="nv"&gt;.preview.example.com&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the env patch stamps the commit SHA as a pod annotation:&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="c1"&gt;# pr-env-vars-patch.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-deployment&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&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;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app/commit-sha&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${COMMIT_SHA}"&lt;/span&gt;   &lt;span class="c1"&gt;# changes on each push -&amp;gt; forces rollout&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PUBLIC_URL&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api-${PR_NUMBER}.preview.example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why bother? When you push a new commit to the same PR, the image tag stays the same (&lt;code&gt;pr-1234&lt;/code&gt;), so the pod spec wouldn't change and Kubernetes wouldn't redeploy. Stamping the new &lt;code&gt;COMMIT_SHA&lt;/code&gt; into an annotation forces a fresh rollout on every push.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Cleanup is automatic
&lt;/h2&gt;

&lt;p&gt;There's no teardown job. When PR #1234 closes (or loses its &lt;code&gt;build/image-ready&lt;/code&gt; label):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On the next 5-minute poll, the input provider stops emitting an input for #1234.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;ResourceSet&lt;/code&gt; sees &lt;code&gt;api-pr-1234&lt;/code&gt; no longer maps to a live input and deletes that &lt;code&gt;Kustomization&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Because that &lt;code&gt;Kustomization&lt;/code&gt; has &lt;code&gt;prune: true&lt;/code&gt;, it cascade-deletes everything it owns - Deployment, Service, Ingress, ConfigMap - all gone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole environment unwinds within one reconcile cycle. "What should exist" is derived from "what PRs are open," and Flux drives the cluster toward it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gate the deploy on the build.&lt;/strong&gt; The &lt;code&gt;!build/image-ready&lt;/code&gt; skip rule is doing real work - without it, Flux tries to deploy before CI pushes the image. Set the label as the last CI step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip cluster-scoped resources&lt;/strong&gt; (&lt;code&gt;$patch: delete&lt;/code&gt;). They can't be safely suffixed per PR; share them from common infra.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force rollouts with a SHA annotation&lt;/strong&gt; if your image tag is stable per PR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poll latency is your iteration loop.&lt;/strong&gt; &lt;code&gt;5m&lt;/code&gt; is a reasonable default - push, wait for CI, get a deploy. Shorter means more API calls to your provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dependsOn&lt;/code&gt; shared infra&lt;/strong&gt;, or every preview env races namespace/ingress setup on bootstrap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you use a PAT:&lt;/strong&gt; code-read scope is enough; keep it sealed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The same shape - &lt;em&gt;watch a dynamic source, render a templated resource per item, reconcile both directions&lt;/em&gt; - shows up in Argo &lt;code&gt;ApplicationSet&lt;/code&gt;, Crossplane &lt;code&gt;Composition&lt;/code&gt;, and Terraform &lt;code&gt;for_each&lt;/code&gt;. What the Flux Operator does well is keep it to two CRDs and pure YAML. If you're already on Flux, the cost is one Helm install plus a provider, a &lt;code&gt;ResourceSet&lt;/code&gt;, and a small overlay per service - cheap enough that per-PR previews stop being a "someday" item.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>gitops</category>
      <category>kubernetes</category>
      <category>flux</category>
    </item>
    <item>
      <title>The 80/20 Library-First Monorepo: Why Your Apps Should Be Almost Empty</title>
      <dc:creator>Artur Havrylov</dc:creator>
      <pubDate>Sun, 03 May 2026 19:46:06 +0000</pubDate>
      <link>https://dev.to/artur-havrylov/the-8020-library-first-monorepo-why-your-apps-should-be-almost-empty-1gom</link>
      <guid>https://dev.to/artur-havrylov/the-8020-library-first-monorepo-why-your-apps-should-be-almost-empty-1gom</guid>
      <description>&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.amazonaws.com%2Fuploads%2Farticles%2Frguhfqm7elk9tha7rjbg.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.amazonaws.com%2Fuploads%2Farticles%2Frguhfqm7elk9tha7rjbg.png" alt="The 80/20 Library-First Monorepo"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most monorepos pay lip service to "share code via libraries." In practice, apps grow huge and libraries stay shallow. The &lt;code&gt;shared/&lt;/code&gt; folder becomes a junk drawer. New code goes wherever's easiest, which is usually wherever the developer is already typing - the app.&lt;/p&gt;

&lt;p&gt;After scaling our frontend monorepo to 19 applications and 86 libraries (roughly 74,000 lines of TypeScript), I've seen this pattern from both sides: as the architect setting it up, and as the engineer who later inherits it. The takeaway: if your monorepo's library-first principle is just a guideline, it will erode. Mechanical enforcement is the only thing that scales.&lt;/p&gt;

&lt;p&gt;Here's how we made library-first an architectural rule that's hard to violate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "Just Put It in a Library" Fails
&lt;/h2&gt;

&lt;p&gt;The default state of any monorepo: convention says "extract reusable code into libraries"; reality says "I'll do it later." Apps grow into hundreds of files because there's zero friction adding code there. Libraries stay thin because extraction is always an explicit decision someone has to make.&lt;/p&gt;

&lt;p&gt;This isn't a discipline problem. It's a friction problem.&lt;/p&gt;

&lt;p&gt;Three failure modes I keep seeing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Apps as code containers.&lt;/strong&gt; A junior dev needs a button component. They write it in &lt;code&gt;apps/foo/src/components/Button.tsx&lt;/code&gt;. Two months later, three other apps have their own slightly different button. None know the others exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared as junk drawer.&lt;/strong&gt; Senior devs do extract code, but &lt;code&gt;libs/shared/&lt;/code&gt; becomes a flat dumping ground with no internal structure. Finding a util becomes a 5-minute grep through &lt;code&gt;libs/shared/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type confusion.&lt;/strong&gt; "What kind of library is this?" "It's a library." UI components, business logic, API clients, and state stores all mixed together because the monorepo doesn't have language to distinguish them.&lt;/p&gt;

&lt;p&gt;Tools like Nx provide module boundaries you &lt;em&gt;can&lt;/em&gt; enforce. Unless they're enforced &lt;em&gt;automatically&lt;/em&gt;, they aren't enforced.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 80/20 Inversion
&lt;/h2&gt;

&lt;p&gt;Our principle: &lt;strong&gt;applications are deployment containers, not code containers.&lt;/strong&gt; They wire libraries together and configure routing. That's it.&lt;/p&gt;

&lt;p&gt;How thin is "thin"? Look at one of our domain apps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;5 source files&lt;/strong&gt; in the app&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;143 source files&lt;/strong&gt; in the domain's libraries&lt;/li&gt;
&lt;li&gt;That's &lt;strong&gt;97% library code, 3% app code&lt;/strong&gt;. We don't just hit 80/20, we exceed it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app folder has roughly four files: &lt;code&gt;main.tsx&lt;/code&gt; (bootstrap), &lt;code&gt;app.tsx&lt;/code&gt; (layout shell), &lt;code&gt;routes.tsx&lt;/code&gt; (route config), and &lt;code&gt;styles.scss&lt;/code&gt;. Here's how they wire together.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;main.tsx&lt;/code&gt; mounts the router and wraps it with cross-cutting providers. Note where these providers come from: &lt;code&gt;shared-ui&lt;/code&gt; and third-party packages, &lt;em&gt;not&lt;/em&gt; domain-state. State libraries hold stores; cross-cutting concerns belong in shared.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/main.tsx&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;createBrowserRouter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RouterProvider&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;react-router-dom&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;QueryClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;QueryClientProvider&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;@tanstack/react-query&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;ErrorBoundary&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;@scope/shared-ui&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;routes&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;./app/routes&lt;/span&gt;&lt;span class="dl"&gt;'&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;queryClient&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;QueryClient&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;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBrowserRouter&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="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorBoundary&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;QueryClientProvider&lt;/span&gt; &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RouterProvider&lt;/span&gt; &lt;span class="na"&gt;router&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;QueryClientProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ErrorBoundary&lt;/span&gt;&lt;span class="p"&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;The real wiring happens in &lt;code&gt;routes.tsx&lt;/code&gt; - this is where features (each a separate library) connect to URL paths, lazy-loaded so each feature ships as its own bundle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app/routes.tsx&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;lazy&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;react&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="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RouteObject&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;react-router-dom&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;App&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;../app&lt;/span&gt;&lt;span class="dl"&gt;'&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;DashboardPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lazy&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;@scope/domain-features-dashboard&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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DashboardPage&lt;/span&gt; &lt;span class="p"&gt;})),&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;SearchPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lazy&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;@scope/domain-features-search&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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SearchPage&lt;/span&gt; &lt;span class="p"&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;const&lt;/span&gt; &lt;span class="nx"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RouteObject&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;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;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;App&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;,&lt;/span&gt;
    &lt;span class="na"&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;index&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;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DashboardPage&lt;/span&gt; &lt;span class="p"&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="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;search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SearchPage&lt;/span&gt; &lt;span class="p"&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="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 the pattern: app boots, mounts routes, routes import features by name, features arrive as their own library. Adding a feature is a one-line addition to the routes file plus a new lib. Apps shrink to their essence; libraries hold everything that matters.&lt;/p&gt;

&lt;p&gt;When code lives in libraries, it's testable in isolation, code-splittable per feature, reusable across apps, and promotable to shared without restructuring. When it lives inside the app, none of those properties hold. &lt;strong&gt;That's why we keep apps almost empty - in this structure, those properties are the default rather than something we have to engineer.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Type-Tagged Boundaries
&lt;/h2&gt;

&lt;p&gt;Reusable libraries aren't enough on their own. You also need a vocabulary for &lt;em&gt;what kind&lt;/em&gt; of library something is, plus rules about which kinds can depend on which.&lt;/p&gt;

&lt;p&gt;We use 7 types, each with explicit dependency constraints:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source type&lt;/th&gt;
&lt;th&gt;Can depend on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type:app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;feature, ui, data-access, state, util, hooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type:feature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;feature, ui, data-access, state, util, hooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type:state&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;state, data-access, util&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type:ui&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ui, util&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type:data-access&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;data-access, util&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type:hooks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;hooks, data-access, util&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type:util&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;util&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read top-to-bottom: &lt;code&gt;type:util&lt;/code&gt; is the foundation - pure functions, no deps. &lt;code&gt;type:ui&lt;/code&gt; adds presentation - components built from utils, no business logic. &lt;code&gt;type:data-access&lt;/code&gt; and &lt;code&gt;type:state&lt;/code&gt; handle the data layer. &lt;code&gt;type:feature&lt;/code&gt; orchestrates everything into pages and flows. &lt;code&gt;type:app&lt;/code&gt; consumes features.&lt;/p&gt;

&lt;p&gt;The rules are encoded in ESLint via &lt;code&gt;@nx/enforce-module-boundaries&lt;/code&gt;. A &lt;code&gt;type:ui&lt;/code&gt; library that imports from &lt;code&gt;type:state&lt;/code&gt; fails lint. Not a warning - an error that blocks merge.&lt;/p&gt;

&lt;p&gt;Why this matters: most monorepos treat "shared code" as a single category. We split it into seven, each with a contract about what it can know and depend on. Small change, outsized effect: a &lt;code&gt;type:ui&lt;/code&gt; library is &lt;em&gt;guaranteed&lt;/em&gt; to be presentation-only, because the toolchain prevents anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalization Across Domains and Shared
&lt;/h2&gt;

&lt;p&gt;Every domain follows the same 6-library template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;libs/{domain}/
├── data-access/    # API clients, types, services
├── state/          # State stores
├── ui/             # Domain-specific components
├── util/           # Constants, helpers
├── hooks/          # Custom hooks
└── features/       # Pages and flows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This uniformity matters more than it sounds. New developer joining a domain? They already know the layout from the previous one. Cross-team handoff? No mental retooling. Promoting someone to a new team? They're productive on day one.&lt;/p&gt;

&lt;p&gt;But the bigger insight - the one that took me longest to appreciate - is that &lt;strong&gt;&lt;code&gt;libs/shared/&lt;/code&gt; follows the exact same structure&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;libs/shared/
├── data-access/
├── ui/
├── util/
├── hooks/
└── features/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same types. Same boundary rules. Same package conventions. Just consumed across all domains instead of one.&lt;/p&gt;

&lt;p&gt;This solves a chronic monorepo problem: the promotion path from domain to shared.&lt;/p&gt;

&lt;p&gt;In most setups, "this should be reusable" triggers a structural refactor: invent a new layout for &lt;code&gt;shared/&lt;/code&gt;, rename packages, update imports, fight the boundary checker. Devs put it off, and &lt;code&gt;shared/&lt;/code&gt; either stays empty or becomes the junk drawer.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;shared/&lt;/code&gt; mirrors domain structure, &lt;strong&gt;the structural problem is already solved.&lt;/strong&gt; What varies is how much &lt;em&gt;contract generalization&lt;/em&gt; each type needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leaf types (&lt;code&gt;util&lt;/code&gt;, &lt;code&gt;ui&lt;/code&gt;, &lt;code&gt;hooks&lt;/code&gt;):&lt;/strong&gt; Often a &lt;code&gt;git mv&lt;/code&gt;. Pure functions, presentation components, and most hooks promote nearly mechanically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;data-access&lt;/code&gt;:&lt;/strong&gt; Needs generalization. Domain endpoints and types must become generic interfaces. Real work, but the structure is solved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;state&lt;/code&gt; and &lt;code&gt;features&lt;/code&gt;:&lt;/strong&gt; Hardest. They depend on multiple other types, and feature units often carry domain assumptions. Promoting a feature to shared is significant work - but the &lt;em&gt;organizational&lt;/em&gt; problem is still solved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The promotion friction goes from "refactor structure + generalize API" to just "generalize API." Roughly half the work, removed by symmetry alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mechanical Enforcement (5 Layers)
&lt;/h2&gt;

&lt;p&gt;Architecture as a guideline fails. Architecture as 5 reinforcing automated checks survives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: The Generator.&lt;/strong&gt; New domains aren't hand-built. We run one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nx g @workspace/domain-app:create &lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-domain
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This scaffolds the app + 6 libraries, &lt;em&gt;correctly tagged from birth&lt;/em&gt;. No after-the-fact tagging where someone forgets &lt;code&gt;type:ui&lt;/code&gt; and the rule never matches. Tagging happens at scaffold time, by code, on every domain. This is the most important layer because it makes "incorrectly structured library" nearly unreachable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: ESLint module boundaries.&lt;/strong&gt; Catches dependency violations at lint time. CI runs &lt;code&gt;nx affected -t lint&lt;/code&gt; on every PR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3: Lint-staged pre-commit.&lt;/strong&gt; Runs &lt;code&gt;eslint --fix&lt;/code&gt; on staged files. Quick errors get auto-fixed; structural errors block commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 4: Husky pre-push.&lt;/strong&gt; Before code leaves the developer's machine: &lt;code&gt;knip:affected&lt;/code&gt; (unused imports), &lt;code&gt;typecheck&lt;/code&gt;, &lt;code&gt;lint:affected:fix&lt;/code&gt;. Slower checks that catch what pre-commit missed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 5: CI gates.&lt;/strong&gt; Final wall: build, test, lint, typecheck on all affected projects. PRs can't merge if any layer below let something through.&lt;/p&gt;

&lt;p&gt;A single layer fails because devs can disable rules locally, skip hooks with &lt;code&gt;--no-verify&lt;/code&gt;, or just push without running tests. Five layers reinforcing the same architecture mean violations would have to slip past every checkpoint - including the generator that won't produce non-conformant code in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Tradeoffs
&lt;/h2&gt;

&lt;p&gt;This pattern isn't free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adoption friction.&lt;/strong&gt; A new contributor walks into 6+ libraries per domain. There's a learning curve while the structure clicks - usually a few days. After that, the layout is identical across domains, and the consistency pays for itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generator maintenance.&lt;/strong&gt; Custom generators are code that needs upkeep. When Nx upgrades, when conventions change, when a new lib type emerges - the generator changes too. Plan for someone to own this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doesn't fit small projects.&lt;/strong&gt; A single-app monorepo doesn't benefit from 6 libraries per domain - it's just complexity. Break-even is somewhere around 3+ apps with shared concerns. Below that, fewer libraries with looser boundaries is the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The General Principle
&lt;/h2&gt;

&lt;p&gt;The Nx-specific details are tactical. The principle is portable to any monorepo (Turborepo, Bazel, Yarn workspaces, Rush): &lt;strong&gt;structure should be enforced mechanically, not socially.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you rely on developers to "remember to put it in the right library" or "remember to tag it correctly," you've already lost. The friction accumulates. Apps grow. &lt;code&gt;shared/&lt;/code&gt; becomes the junk drawer.&lt;/p&gt;

&lt;p&gt;Make the right thing easy and the wrong thing hard. Then your architecture is real.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>frontend</category>
      <category>softwareengineering</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
