<?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: Mahmoud Farouk</title>
    <description>The latest articles on DEV Community by Mahmoud Farouk (@mfarouk2894).</description>
    <link>https://dev.to/mfarouk2894</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%2F3875582%2Fbdb248d1-ad93-47f1-836f-055c00424e24.jpg</url>
      <title>DEV Community: Mahmoud Farouk</title>
      <link>https://dev.to/mfarouk2894</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mfarouk2894"/>
    <language>en</language>
    <item>
      <title>Hibernate filters don't apply to findById: a multi-tenancy leak in Spring Boot 4</title>
      <dc:creator>Mahmoud Farouk</dc:creator>
      <pubDate>Wed, 28 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/mfarouk2894/hibernate-filters-dont-apply-to-findbyid-a-multi-tenancy-leak-in-spring-boot-4-64p</link>
      <guid>https://dev.to/mfarouk2894/hibernate-filters-dont-apply-to-findbyid-a-multi-tenancy-leak-in-spring-boot-4-64p</guid>
      <description>&lt;p&gt;Single-schema multi-tenancy with a Hibernate filter is a popular pattern. Every tenant table has a &lt;code&gt;tenant_id&lt;/code&gt; column, a &lt;code&gt;@Filter&lt;/code&gt; adds &lt;code&gt;tenant_id = :tenantId&lt;/code&gt; to every query, and an aspect turns the filter on for each request. Lists, searches and reports are all isolated, and the tests for them pass.&lt;/p&gt;

&lt;p&gt;There is one hole in it that is easy to miss, and my own starter kit had it. This is how it works and how version 5.3 closes it in two layers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The gap described here is fixed in Arab Enterprise Kit 5.3, and the update is free for every v5.x license.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The hole
&lt;/h2&gt;

&lt;p&gt;Spring Data's &lt;code&gt;SimpleJpaRepository.findById&lt;/code&gt; calls &lt;code&gt;EntityManager.find&lt;/code&gt;. Hibernate filters apply to queries (HQL, JPQL, Criteria and filtered collections). They do not apply to loading an entity by its primary key.&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="c1"&gt;// Looks safe, is not: EntityManager.find ignores the tenant filter&lt;/span&gt;
&lt;span class="nc"&gt;Invoice&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;invoiceRepository&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="mi"&gt;42L&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;GET /api/invoices/42&lt;/code&gt; sent by a user of company A can load company B's invoice, as long as A knows or guesses the id. Every list endpoint still looks perfectly isolated, because lists go through queries. A test suite that only checks listings will never see it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;deleteById&lt;/code&gt; goes through &lt;code&gt;findById&lt;/code&gt;, so it has the same gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: make findById a query
&lt;/h2&gt;

&lt;p&gt;The fix at the repository level is to run the lookup as a JPQL query, so the filter applies. The kit does it once, in a repository base class:&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;class&lt;/span&gt; &lt;span class="nc"&gt;TenantAwareRepository&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;,&lt;/span&gt; &lt;span class="no"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;SimpleJpaRepository&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;,&lt;/span&gt; &lt;span class="no"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;EntityManager&lt;/span&gt; &lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;JpaEntityInformation&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;,&lt;/span&gt; &lt;span class="o"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;entityInformation&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;TenantAwareRepository&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JpaEntityInformation&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;,&lt;/span&gt; &lt;span class="o"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;entityInformation&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EntityManager&lt;/span&gt; &lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entityInformation&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entityManager&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;entityManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entityManager&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;entityInformation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entityInformation&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&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="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ID&lt;/span&gt; &lt;span class="n"&gt;id&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;id&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"The given id must not be null"&lt;/span&gt;&lt;span class="o"&gt;);&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;idName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entityInformation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRequiredIdAttribute&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getName&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;jpql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"select e from "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;entityInformation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEntityName&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" e where e."&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;idName&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;" = :id"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createQuery&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jpql&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;getDomainClass&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setParameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&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="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getResultStream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findFirst&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;and registers it for every repository:&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;@EnableJpaRepositories&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repositoryBaseClass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TenantAwareRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;deleteById&lt;/code&gt; calls &lt;code&gt;findById&lt;/code&gt;, deletes are covered too. Entities that have no tenant filter (subscription plans, for example) behave exactly as before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: an explicit check in the services
&lt;/h2&gt;

&lt;p&gt;A repository base class is easy to bypass later: someone adds a native query or calls the &lt;code&gt;EntityManager&lt;/code&gt; directly. So the services that load tenant data by id also check ownership:&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;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TenantGuard&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;TenantGuard&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;ownedByCurrentTenant&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BaseEntity&lt;/span&gt; &lt;span class="n"&gt;entity&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="nf"&gt;ownedByCurrentTenant&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTenantId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;ownedByCurrentTenant&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;entityTenantId&lt;/span&gt;&lt;span class="o"&gt;)&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;tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TenantContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTenantId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tenantId&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;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;tenantId&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;entityTenantId&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Invoice&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;invoiceRepository&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="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;TenantGuard:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;ownedByCurrentTenant&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Invoice not found: "&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the error: another tenant's invoice looks exactly like a missing one. Answering "forbidden" would tell the caller that id 42 exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail closed when there is no tenant
&lt;/h2&gt;

&lt;p&gt;The aspect that enables the filter has one more decision to make: what happens when no tenant is set at all? A scheduled job or a background thread that forgets to set it should not see every tenant's data.&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="n"&gt;tenantId&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;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isTenantAgnostic&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;joinPoint&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="n"&gt;tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;NO_TENANT&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "__no_tenant__" matches no row&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enableFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tenantFilter"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;setParameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tenantId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The few services that really run without a tenant (sign-in, registration, payment webhooks that read the tenant from the payload) opt out with a &lt;code&gt;@TenantAgnostic&lt;/code&gt; annotation. Everything else sees nothing instead of everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tests that hold it in place
&lt;/h2&gt;

&lt;p&gt;The kit's isolation tests seed two tenants and check each path, not just the listing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;listing as tenant A does not return tenant B's items&lt;/li&gt;
&lt;li&gt;reading tenant B's item by id as tenant A is rejected&lt;/li&gt;
&lt;li&gt;updating it by id is rejected and leaves it unchanged&lt;/li&gt;
&lt;li&gt;deleting it by id is rejected and keeps the item&lt;/li&gt;
&lt;li&gt;a background thread with tenant B's context sees only tenant B's items&lt;/li&gt;
&lt;li&gt;a background thread with no tenant context sees no tenant data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your isolation tests only cover the first line, they would not have caught this.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist for your own code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Search for &lt;code&gt;findById&lt;/code&gt;, &lt;code&gt;getReferenceById&lt;/code&gt;, &lt;code&gt;deleteById&lt;/code&gt; and any direct &lt;code&gt;EntityManager.find&lt;/code&gt; or &lt;code&gt;getReference&lt;/code&gt; on tenant entities.&lt;/li&gt;
&lt;li&gt;Decide where the guarantee lives: a repository base class, a service check, or both.&lt;/li&gt;
&lt;li&gt;Make "no tenant" mean "no data".&lt;/li&gt;
&lt;li&gt;Write the by-id tests, not only the list tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Talk to me
&lt;/h2&gt;

&lt;p&gt;This is the first post in a series about building &lt;a href="https://arab-enterperise-kit-production.up.railway.app" rel="noopener noreferrer"&gt;Arab Enterprise Kit&lt;/a&gt;, an Arabic-first, multi-tenant SaaS foundation in Java 25, Spring Boot 4 and Angular 21. There's a free &lt;a href="https://github.com/MahmoudFarouk2479/Arab-Enterprise-Kit-Lite" rel="noopener noreferrer"&gt;Lite edition on GitHub&lt;/a&gt;, and the full kit is on &lt;a href="https://mahmoudfarouk28.gumroad.com/l/arab-enterprise-kit-V5-solo" rel="noopener noreferrer"&gt;Gumroad&lt;/a&gt;. I build SaaS products and take freelance full-stack and backend projects: &lt;a href="https://mahmoud-farouk-portfolio.vercel.app" rel="noopener noreferrer"&gt;mahmoud-farouk-portfolio.vercel.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;How does your multi-tenant app handle lookups by id?&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>security</category>
      <category>hibernate</category>
    </item>
    <item>
      <title>Four details I check in any SaaS starter kit, and how mine handles them (Spring Boot 4 + Angular 21)</title>
      <dc:creator>Mahmoud Farouk</dc:creator>
      <pubDate>Mon, 26 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/mfarouk2894/four-details-i-check-in-any-saas-starter-kit-and-how-mine-handles-them-spring-boot-4-angular-21-5c5n</link>
      <guid>https://dev.to/mfarouk2894/four-details-i-check-in-any-saas-starter-kit-and-how-mine-handles-them-spring-boot-4-angular-21-5c5n</guid>
      <description>&lt;p&gt;A starter kit's feature list tells you very little. Everyone has "auth, billing, multi-tenancy". What tells you whether a kit is safe to build a business on is a handful of small details: how it stores secrets, what happens when audit logging fails, how it trusts a payment webhook.&lt;/p&gt;

&lt;p&gt;I maintain a SaaS starter kit called the Arab Enterprise Kit: Spring Boot 4 on Java 25, Angular 21 with PrimeNG, PostgreSQL, Docker, multi-tenancy with a Hibernate filter, Stripe billing, TOTP two-factor auth, Google OAuth2 and a three-language UI (English, Arabic right-to-left, French). Instead of touring the features, here are four details I would look at in any kit, with the real code behind each.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. API keys are shown once and stored as hashes
&lt;/h2&gt;

&lt;p&gt;If an API key table leaks, every key in it is compromised, unless you stored only hashes. The kit generates a key from 32 random bytes, stores a SHA-256 hash and a short prefix for display, and returns the raw key exactly once:&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;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;32&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;SecureRandom&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;nextBytes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&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;rawKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ak_"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;Base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUrlEncoder&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;withoutPadding&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;encodeToString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setKeyHash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawKey&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setKeyPrefix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;substring&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rawKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="o"&gt;())));&lt;/span&gt;
&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setIsActive&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;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setExpiresAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resolvedExpiresAt&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="nc"&gt;ApiKey&lt;/span&gt; &lt;span class="n"&gt;saved&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;key&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Return the raw key only once&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ApiKeyResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromWithRaw&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rawKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authentication hashes the presented key and looks the hash up, then checks that the key is active and not expired:&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;return&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;findByKeyHash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawKey&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;ApiKey:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getIsActive&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getExpiresAt&lt;/span&gt;&lt;span class="o"&gt;()&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="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getExpiresAt&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="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Revoking a key flips a flag, so a compromised key stops working immediately without deleting its history. API keys are high-entropy random values, which is why a fast hash is appropriate here; passwords are a different matter and use BCrypt.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Audit logging by annotation, and it must never break the request
&lt;/h2&gt;

&lt;p&gt;Audit trails get forgotten when every service method has to remember to write one. In the kit a method opts in with an annotation:&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;@Target&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ElementType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;METHOD&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Retention&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RetentionPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RUNTIME&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nd"&gt;@interface&lt;/span&gt; &lt;span class="nc"&gt;Loggable&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;AuditAction&lt;/span&gt; &lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;entityName&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s"&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;An aspect runs after the method returns, reads the user from the security context, the client IP from the request, and the entity id from the returned entity, then saves the log entry:&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;@AfterReturning&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pointcut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"@annotation(...Loggable)"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"result"&lt;/span&gt;&lt;span class="o"&gt;)&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;logActivity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JoinPoint&lt;/span&gt; &lt;span class="n"&gt;joinPoint&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// username, ip, entityId from the result...&lt;/span&gt;
        &lt;span class="n"&gt;auditLogService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;saveAuditLog&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loggable&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;loggable&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;entityName&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;entityId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;details&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;shouldSendNotification&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* only CREATE, UPDATE, DELETE */&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Audit Log Failed: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&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;Two decisions are worth copying. Reads do not send notifications, only create, update and delete do. And the whole body is wrapped in a &lt;code&gt;try/catch&lt;/code&gt;, so a failure in the audit path is logged but cannot turn a successful business operation into an error.&lt;/p&gt;

&lt;p&gt;That second choice is a trade-off, and you should make it consciously. If you need a guarantee that no action happens without an audit record, you want the opposite: fail the operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A payment webhook is untrusted input until it is verified
&lt;/h2&gt;

&lt;p&gt;Stripe tells your server about payments by calling a public endpoint, so that endpoint cannot use your normal login. The kit excludes it from JWT authentication and relies on Stripe's signature instead:&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;"/webhook"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&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;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleWebhook&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@RequestHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Stripe-Signature"&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;sigHeader&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;constructEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sigHeader&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;webhookSecret&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SignatureVerificationException&lt;/span&gt; &lt;span class="n"&gt;e&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="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;badRequest&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Invalid signature"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getType&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"checkout.session.completed"&lt;/span&gt;    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stripeWebhookService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;handleCheckoutSessionCompleted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"invoice.payment_succeeded"&lt;/span&gt;     &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stripeWebhookService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;handleInvoicePaymentSucceeded&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"invoice.payment_failed"&lt;/span&gt;        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stripeWebhookService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;handleInvoicePaymentFailed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"customer.subscription.deleted"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stripeWebhookService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;handleSubscriptionDeleted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"customer.subscription.updated"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stripeWebhookService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;handleSubscriptionUpdated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unhandled Stripe event type: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getType&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="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Received"&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 raw body is passed to &lt;code&gt;Webhook.constructEvent&lt;/code&gt; exactly as received, because re-serialising it would break the signature. Anything that fails verification gets a 400 and is never parsed into an event. The secret defaults to a placeholder value, so an unconfigured install rejects every call instead of accepting them.&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%2Fhib6mwruu2bvb0232c3f.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%2Fhib6mwruu2bvb0232c3f.png" alt="The kit's subscription plans screen: Free, Pro and Enterprise plans with user and usage limits" width="800" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A rate limiter that is honest about what it is
&lt;/h2&gt;

&lt;p&gt;Login endpoints attract brute force. The kit ships a deliberately small limiter for &lt;code&gt;/api/auth/**&lt;/code&gt;: 20 requests per IP per 60-second window, then HTTP 429.&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;RequestCount&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ipBuckets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;compute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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="o"&gt;-&amp;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;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="n"&gt;now&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;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;windowStart&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;plusSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;WINDOW_SECONDS&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="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;RequestCount&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;AtomicInteger&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;incrementAndGet&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;existing&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;Its own comment is the part I like: it says it is an in-memory, zero-dependency implementation, and that for production behind multiple instances you should replace it with Redis-based limiting. Per-account lockout is a separate layer: after 5 failed attempts the account is locked for 15 minutes.&lt;/p&gt;

&lt;p&gt;A starter kit does not have to be perfect. It has to tell you where its limits are, so you can decide what to harden before real traffic arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell my past self
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Store only hashes of secrets you can show once.&lt;/li&gt;
&lt;li&gt;Make cross-cutting concerns (audit, notifications) annotations, and decide deliberately whether their failure may break the request.&lt;/li&gt;
&lt;li&gt;Verify webhook signatures against the raw body, and fail closed when the secret is not configured.&lt;/li&gt;
&lt;li&gt;Document the limits of every "simple" component in the component itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Talk to me
&lt;/h2&gt;

&lt;p&gt;The kit is also on &lt;a href="https://mahmoudfarouk28.gumroad.com/l/arab-enterprise-kit-V5-solo" rel="noopener noreferrer"&gt;Gumroad&lt;/a&gt;. I build SaaS products and take freelance full-stack and backend projects: &lt;a href="https://mahmoud-farouk-portfolio.vercel.app" rel="noopener noreferrer"&gt;mahmoud-farouk-portfolio.vercel.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What is the first thing you check when you evaluate a starter kit?&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>angular</category>
      <category>saas</category>
    </item>
    <item>
      <title>A Next.js 16 portfolio you edit in one file, with accessibility and video behaviour under test</title>
      <dc:creator>Mahmoud Farouk</dc:creator>
      <pubDate>Mon, 19 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/mfarouk2894/a-nextjs-16-portfolio-you-edit-in-one-file-with-accessibility-and-video-behaviour-under-test-2330</link>
      <guid>https://dev.to/mfarouk2894/a-nextjs-16-portfolio-you-edit-in-one-file-with-accessibility-and-video-behaviour-under-test-2330</guid>
      <description>&lt;p&gt;A developer portfolio is a small project that tends to rot. The copy lives in ten components, the video autoplays on a phone with data saver on, the contact form validates in two places that slowly disagree, and nobody has run an accessibility check since launch.&lt;/p&gt;

&lt;p&gt;I built a portfolio template (Next.js 16 App Router, React 19, Tailwind CSS v4, TypeScript) to avoid exactly that. This post is about the five design decisions that made it easy to keep correct, with the real code behind each.&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%2Fmo2fm6xi814cw2e3h3ub.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%2Fmo2fm6xi814cw2e3h3ub.png" alt="The portfolio template: hero section with headline, call to action and portrait placeholder" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. All content in one typed file
&lt;/h2&gt;

&lt;p&gt;Every word, link, project and SEO value lives in &lt;code&gt;lib/content.ts&lt;/code&gt;. Components import from it and contain no copy. To personalise the site you edit that one file.&lt;/p&gt;

&lt;p&gt;The point is not only convenience. A single typed source of truth can be tested. The unit tests check things a reviewer would otherwise check by eye:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;there are exactly three services, and each one is selectable in the contact form;&lt;/li&gt;
&lt;li&gt;the navigation links match the section ids the page uses;&lt;/li&gt;
&lt;li&gt;every image path in the content points at a file that really exists in &lt;code&gt;public/&lt;/code&gt;.
&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;publicFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ...inside a test&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;publicFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A broken image path fails a unit test instead of showing a broken image in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A demo video that costs nothing until you need it
&lt;/h2&gt;

&lt;p&gt;Demo videos are heavy, and autoplaying them is rude. The &lt;code&gt;ScrollVideo&lt;/code&gt; component is a small state machine documented in its own comment:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Before scroll: poster only, no source, nothing downloaded.&lt;/li&gt;
&lt;li&gt;Within 200px of the viewport: the sources are attached.&lt;/li&gt;
&lt;li&gt;At least 55% visible: plays muted and looped. Below that: pauses.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It uses two &lt;code&gt;IntersectionObserver&lt;/code&gt;s, one with a &lt;code&gt;rootMargin&lt;/code&gt; to attach sources early and one with thresholds to start and stop playback:&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="nf"&gt;useEffect&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reducedMotion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(prefers-reduced-motion: reduce)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;matches&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;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Navigator&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;saveData&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;autoplayAllowed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;reducedMotion&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;saveData&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;nearViewport&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;IntersectionObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entries&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isIntersecting&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;setAttached&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="nx"&gt;nearViewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&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="na"&gt;rootMargin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;200px 0px&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;// a second observer with threshold [0, 0.55, 1] starts and pauses playback&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;play&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details I care about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;People who asked for less motion get less motion.&lt;/strong&gt; With &lt;code&gt;prefers-reduced-motion&lt;/code&gt; or data saver on, nothing autoplays. The poster stays and an explicit play button is available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is always a visible pause button&lt;/strong&gt; (WCAG 2.2.2), with the project name in screen-reader-only text, and a manual pause is remembered so scrolling does not restart it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The state is observable.&lt;/strong&gt; The wrapper exposes &lt;code&gt;data-state="idle | playing | paused"&lt;/code&gt;, which makes the behaviour testable instead of a matter of faith.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. One validation function for the browser and the server
&lt;/h2&gt;

&lt;p&gt;The contact form calls &lt;code&gt;validateContact&lt;/code&gt; in the browser, and the API route calls the same function again on the server. There is no second set of rules to drift out of sync.&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="cm"&gt;/** Validates the contact payload. `company` is a honeypot: humans never see it. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateContact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ContactResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;company&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="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&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;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;spam&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="c1"&gt;// name, email pattern, service in the allowed list, message length 40..4000&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route treats the honeypot differently from a normal error:&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="c1"&gt;// Pretend success to bots so they do not learn the honeypot exists.&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spam&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delivery goes through Resend when three environment variables are set. When they are not, the route answers 503 with a clear message and the UI falls back to a direct email link, so a half-configured deployment degrades into something usable instead of a dead form.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Analytics events defined in one place
&lt;/h2&gt;

&lt;p&gt;Custom events are a classic source of drift: &lt;code&gt;cta_click&lt;/code&gt; in one file, &lt;code&gt;ctaClick&lt;/code&gt; in another. Here every event is a member of one union type:&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;type&lt;/span&gt; &lt;span class="nx"&gt;AnalyticsEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&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="s2"&gt;cta_click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;start_project&lt;/span&gt;&lt;span class="dl"&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;resume&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;location&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;span class="o"&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="s2"&gt;service_interest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;service&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;span class="o"&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="s2"&gt;video_play&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;project&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;trigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scroll&lt;/span&gt;&lt;span class="dl"&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;button&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="o"&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="s2"&gt;form_submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&lt;/span&gt;&lt;span class="dl"&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;error&lt;/span&gt;&lt;span class="dl"&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;invalid&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="o"&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="s2"&gt;outbound_click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OutboundTarget&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;function&lt;/span&gt; &lt;span class="nf"&gt;trackEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AnalyticsEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* Analytics must never break the page. */&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;TypeScript now rejects a misspelled event name or a missing property, and the &lt;code&gt;try/catch&lt;/code&gt; means an analytics failure can never break a page.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Accessibility is a test, not a hope
&lt;/h2&gt;

&lt;p&gt;The end-to-end suite runs axe against the page, including WCAG 2.2 AA rules, after scrolling through the whole page so lazy content is present, and again with the mobile menu open. Serious and critical violations fail the build:&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;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AxeBuilder&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withTags&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wcag2a&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="s2"&gt;wcag2aa&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="s2"&gt;wcag21aa&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="s2"&gt;wcag22aa&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;analyze&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;blocking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: ...`&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two further checks are plain DOM assertions: every image has an &lt;code&gt;alt&lt;/code&gt; attribute and every form control has a label. A separate reduced-motion test group checks the video behaviour from section 2, and CI runs lint, typecheck, unit tests and the browser suite on every pull request and on pushes to &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Automated tools find only part of the accessibility problems, so this is a floor and not a certificate. But a floor that runs on every commit beats a manual audit that happened once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell my past self
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Put the content in one typed file and write tests against it.&lt;/li&gt;
&lt;li&gt;Treat video as a progressive enhancement with a visible off switch.&lt;/li&gt;
&lt;li&gt;Share validation between client and server, and give spam a different path.&lt;/li&gt;
&lt;li&gt;Define analytics events as a union type.&lt;/li&gt;
&lt;li&gt;Run axe in CI from day one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Talk to me
&lt;/h2&gt;

&lt;p&gt;I build full-stack products and take freelance projects: &lt;a href="https://mahmoud-farouk-portfolio.vercel.app" rel="noopener noreferrer"&gt;mahmoud-farouk-portfolio.vercel.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What would you add to the checklist for a portfolio that has to stay correct for years?&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>a11y</category>
      <category>testing</category>
    </item>
    <item>
      <title>Designing an AI ad pipeline where a failed job never costs the customer a credit (Spring Boot)</title>
      <dc:creator>Mahmoud Farouk</dc:creator>
      <pubDate>Mon, 12 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/mfarouk2894/designing-an-ai-ad-pipeline-where-a-failed-job-never-costs-the-customer-a-credit-spring-boot-106e</link>
      <guid>https://dev.to/mfarouk2894/designing-an-ai-ad-pipeline-where-a-failed-job-never-costs-the-customer-a-credit-spring-boot-106e</guid>
      <description>&lt;p&gt;AdPlatformForge turns one product photo into a short video ad or a social post design, in Arabic, French or Hassaniya. It is a multi-tenant SaaS: Spring Boot on the back end, Angular on the front, PostgreSQL, FFmpeg for video, and an LLM for the script.&lt;/p&gt;

&lt;p&gt;AI calls are slow, flaky and cost money. This post is about the four design choices that kept the system honest: the pipeline shape, the provider interfaces, the credit ledger and the safety checks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg2p7opdvexc63wj5pl2i.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg2p7opdvexc63wj5pl2i.gif" alt="AdPlatformForge: from product photo and brief to a finished ad, with live progress" width="720" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline is a line of persisted steps
&lt;/h2&gt;

&lt;p&gt;A generation is not one request. It is a production line, and each step writes its status and progress to the database before doing the work:&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;step&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="nc"&gt;GenerationStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ANALYZING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// read the photo and the brief&lt;/span&gt;
&lt;span class="n"&gt;step&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="nc"&gt;GenerationStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ENHANCING_IMAGE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// clean up the product image&lt;/span&gt;
&lt;span class="n"&gt;step&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="nc"&gt;GenerationStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;WRITING_SCRIPT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// headline, lines, call to action&lt;/span&gt;
&lt;span class="c1"&gt;// moderation check here&lt;/span&gt;
&lt;span class="n"&gt;step&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="nc"&gt;GenerationStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SYNTHESIZING_VOICE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;55&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// video modes that speak&lt;/span&gt;
&lt;span class="n"&gt;step&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="nc"&gt;GenerationStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RENDERING&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// design or video&lt;/span&gt;
&lt;span class="c1"&gt;// COMPLETED at 100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The method runs on a dedicated executor with &lt;code&gt;@Async("studioExecutor")&lt;/code&gt;, and the tenant id is set on a thread-local at the start and cleared in a &lt;code&gt;finally&lt;/code&gt; block, because the async thread does not inherit the request context.&lt;/p&gt;

&lt;p&gt;Because every step is persisted, the front end just polls the job and shows real progress, and failures are never silent. There is one more detail I am glad I added. If the server restarts while jobs are running, they can never finish, so on startup they are failed 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="nd"&gt;@EventListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ApplicationReadyEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&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;failInterruptedJobs&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// QUEUED, ANALYZING, ENHANCING_IMAGE, WRITING_SCRIPT, SYNTHESIZING_VOICE, RENDERING&lt;/span&gt;
    &lt;span class="c1"&gt;// -&amp;gt; FAILED, "The server restarted while this job was running. Please create it again."&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A job stuck at "Rendering 70%" forever is the worst experience. A clear failure that refunds the customer is much better.&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%2F96lh1cjrzoyigub509cp.jpg" 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%2F96lh1cjrzoyigub509cp.jpg" alt="A finished social post design with an Arabic headline and offer" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Every AI provider sits behind an interface
&lt;/h2&gt;

&lt;p&gt;The pipeline never talks to a vendor directly. It depends on small interfaces: &lt;code&gt;ScriptWriter&lt;/code&gt;, &lt;code&gt;ImageEnhancer&lt;/code&gt;, &lt;code&gt;VoiceSynthesizer&lt;/code&gt;, &lt;code&gt;AvatarVideoGenerator&lt;/code&gt;, &lt;code&gt;BrollVideoGenerator&lt;/code&gt;, &lt;code&gt;KineticVideoRenderer&lt;/code&gt;, &lt;code&gt;DesignComposer&lt;/code&gt;. Real implementations (an LLM for scripts, an image-cleanup service, a voice service, an avatar-video service, FFmpeg for kinetic video) sit next to mock implementations of the same interfaces.&lt;/p&gt;

&lt;p&gt;This paid off three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Development without keys.&lt;/strong&gt; The whole pipeline runs end to end in a mock mode, so I could build and test the UI and billing without spending anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swappable vendors.&lt;/strong&gt; Changing a provider is a new class and a config change, not a pipeline rewrite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single error type.&lt;/strong&gt; Providers throw a &lt;code&gt;ProviderException&lt;/code&gt; with a message that is safe to show. Anything else becomes a generic "Something went wrong" while the real error goes to the log.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Credits: a failed ad must cost nothing
&lt;/h2&gt;

&lt;p&gt;The product is billed in credits. Money-like numbers deserve a ledger, not a counter, so every movement is a row: plan grants, top-ups, charges, refunds and manual adjustments.&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;CreditEntryType&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;PLAN_GRANT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;PLAN_EXPIRE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;TOPUP&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;GENERATION_CHARGE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;REFUND&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ADJUSTMENT&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two buckets exist per tenant. Plan credits are granted monthly and do not roll over. Top-up credits are purchased and never expire. Spending uses plan credits first. The tenant row is locked while a balance changes, so two parallel generations cannot both spend the same credit.&lt;/p&gt;

&lt;p&gt;The refund is the part I care about most:&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;refund&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;tenantId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;generationId&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;reason&lt;/span&gt;&lt;span class="o"&gt;)&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;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"gen-refund-"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;generationId&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;ledger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;existsByTenantIdAndReference&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&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="c1"&gt;// safe to call twice&lt;/span&gt;
    &lt;span class="c1"&gt;// ...find the original charge, give back exactly what it took from each bucket&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is idempotent through a unique reference on the ledger, so retries and double calls cannot refund twice. It also handles an edge case: if the original charge used plan credits from a previous month, those credits would have expired, so they come back as top-up credits instead of vanishing.&lt;/p&gt;

&lt;p&gt;The pipeline calls the refund from both failure paths, a provider failure and a blocked script:&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="cm"&gt;/** A failed or blocked ad must never cost the customer anything. */&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;refund&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;tenantId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&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;reason&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;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The affordability check also happens before anything is stored (&lt;code&gt;assertAvailable&lt;/code&gt;), so a customer without enough credits fails fast, before any slow work starts.&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%2Ftlcixfoto0epajje4cr7.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%2Ftlcixfoto0epajje4cr7.png" alt="The billing and credits screen" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Safety: a conservative guard plus a human
&lt;/h2&gt;

&lt;p&gt;Generated ads can go wrong in ways that matter: politics, adult content, gambling, alcohol, unverifiable health promises. A &lt;code&gt;ContentGuard&lt;/code&gt; checks the script before any rendering money is spent. It is keyword-based and covers three languages, and its own Javadoc is honest about the limits: it is a conservative safety net, not a replacement for the LLM's system-prompt rules or for manual approval.&lt;/p&gt;

&lt;p&gt;The riskiest feature, using a licensed likeness of a public figure, never relies on a keyword list. It is gated by a valid license, an approval queue, a usage log of every request (approved or not) and a kill switch. A blocked request is marked &lt;code&gt;REJECTED&lt;/code&gt;, logged and refunded.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell my past self
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Persist every pipeline step. Progress bars and failure handling come for free.&lt;/li&gt;
&lt;li&gt;Put each vendor behind an interface and build the mocks first.&lt;/li&gt;
&lt;li&gt;Treat credits as a ledger with idempotent operations and exact refunds.&lt;/li&gt;
&lt;li&gt;Run cheap checks (balance, moderation) before expensive work.&lt;/li&gt;
&lt;li&gt;Be honest in code comments about what a safety check does not cover.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Talk to me
&lt;/h2&gt;

&lt;p&gt;I build SaaS products, async pipelines and billing systems end to end, and I take freelance full-stack and backend projects: &lt;a href="https://mahmoud-farouk-portfolio.vercel.app" rel="noopener noreferrer"&gt;mahmoud-farouk-portfolio.vercel.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;How do you handle refunds and idempotency in your own billing code? I would like to compare notes.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>ai</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I build my Notion templates as code: generate, review on disk, then push</title>
      <dc:creator>Mahmoud Farouk</dc:creator>
      <pubDate>Mon, 05 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/mfarouk2894/i-build-my-notion-templates-as-code-generate-review-on-disk-then-push-37a9</link>
      <guid>https://dev.to/mfarouk2894/i-build-my-notion-templates-as-code-generate-review-on-disk-then-push-37a9</guid>
      <description>&lt;p&gt;Most Notion templates are built by hand: click a database into existence, add properties, drag views around, copy the page. That works once. It does not work when you want to rebuild it after a Notion change, review exactly what it will create, or ship several templates that share a style.&lt;/p&gt;

&lt;p&gt;So I build mine as code. Each template is a small Node.js project that generates a list of Notion API calls, lets me read them, and only then pushes them. This post walks through the design using one of them, an Architecture Snippet Vault: a Snippets database and a Collections database linked both ways, six views, a dashboard page and sample content.&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%2F4naf5q895c1zk9n057jo.jpg" 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%2F4naf5q895c1zk9n057jo.jpg" alt="The Architecture Snippet Vault dashboard in Notion, with a quick start list and six views" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two steps, and a review in between
&lt;/h2&gt;

&lt;p&gt;The script works in two steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Local review.&lt;/strong&gt; It writes the exact API payloads to &lt;code&gt;schema.json&lt;/code&gt; and the page text to &lt;code&gt;layout.md&lt;/code&gt;. No token and no network needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push.&lt;/strong&gt; It shows what it will create, asks for confirmation, and on &lt;code&gt;yes&lt;/code&gt; runs &lt;strong&gt;the files on disk&lt;/strong&gt; through the official &lt;code&gt;@notionhq/client&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second point matters. What gets pushed is not regenerated in memory. It is the file I (or a buyer) just edited, so a changed property name or a deleted view in &lt;code&gt;schema.json&lt;/code&gt; is exactly what reaches Notion.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Regenerates files&lt;/th&gt;
&lt;th&gt;Prompts&lt;/th&gt;
&lt;th&gt;Pushes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm run review&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm start&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes (overwrites edits)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;on &lt;code&gt;yes&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm run push&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;on &lt;code&gt;yes&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The confirmation is a plain &lt;code&gt;yes&lt;/code&gt;. Anything else exits without contacting Notion.&lt;/p&gt;

&lt;h2&gt;
  
  
  An allowlist instead of trust
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;schema.json&lt;/code&gt; is an ordered list of API calls (33 of them for this template). A file that tells a script what to call is a file that could call anything, so the runner only accepts a fixed set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The only API calls schema.json may make. An edited or foreign schema cannot call anything else.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CALLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pages.create&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;notion&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pages.updateMarkdown&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;notion&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateMarkdown&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;databases.create&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notion&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dataSources.update&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;notion&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataSources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;views.create&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;notion&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;views&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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="c1"&gt;// plus retrieve/list/update helpers&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;())&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;CALLS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&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="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`schema.json step &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&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="s2"&gt;: unsupported call "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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 whole schema is validated before the first request is sent, so a bad step fails early instead of half way through a push.&lt;/p&gt;

&lt;h2&gt;
  
  
  IDs that do not exist yet
&lt;/h2&gt;

&lt;p&gt;The awkward part of building Notion structures through an API is that IDs only exist after something is created: the page, the database, every property. A relation needs the other database's ID, and a view refers to properties by ID.&lt;/p&gt;

&lt;p&gt;The generated steps solve this with placeholders and a &lt;code&gt;capture&lt;/code&gt; map. A step says which fields of its response to remember, and later steps use them as &lt;code&gt;{{...}}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runStep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chunks&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;response&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;CALLS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&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="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dotted&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;capture&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dotted&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;value&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&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="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`The &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; response has no "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dotted&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" to keep as &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// View configurations refer to properties by ID, which Notion only reveals after creation.&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;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;captureProperties&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* remember PROPERTY_ID:&amp;lt;database&amp;gt;:&amp;lt;name&amp;gt; */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&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 a placeholder has no value, the error says which step should have provided it and that it "failed, was skipped, or comes later in schema.json". The message points straight at the step that is missing something, instead of leaving you to guess.&lt;/p&gt;

&lt;p&gt;Order matters too. Databases are appended to the end of a page, so the text around them has to be inserted in between. &lt;code&gt;layout.md&lt;/code&gt; carries two &lt;code&gt;&amp;lt;!-- database: ... --&amp;gt;&lt;/code&gt; markers, and the script splits the text at them into three chunks and interleaves them with the database creation.&lt;/p&gt;

&lt;h2&gt;
  
  
  A formula that keeps itself honest
&lt;/h2&gt;

&lt;p&gt;The vault tracks whether each snippet is still trustworthy. A &lt;code&gt;Review Health&lt;/code&gt; formula labels it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REVIEW_HEALTH_FORMULA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;if(prop("Status") == "Deprecated", "⚫ Deprecated", &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;if(empty(prop("Last Reviewed")), "⚪ Not reviewed", &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;if(dateBetween(now(), prop("Last Reviewed"), "days") &amp;gt; 180, "🔴 Stale", "🟢 Fresh")))&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;A "Review Queue" view is built on top of it, so the next thing to re-check is easy to find.&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%2Fvuqcvr62rzf9ubjkre50.jpg" 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%2Fvuqcvr62rzf9ubjkre50.jpg" alt="The Review Queue view, with a Fresh or Stale review health label on each snippet" width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Formulas that depend on &lt;code&gt;now()&lt;/code&gt; create a problem for templates: sample data ages. If the sample snippets carry fixed review dates, a template built once slowly turns every sample red. I handle it two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sample dates are set &lt;strong&gt;relative to the day the build runs&lt;/strong&gt;, so a fresh build always shows a mix of Fresh and Stale.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;refresh&lt;/code&gt; command re-dates the samples later. It lists the dates it will set, asks for &lt;code&gt;yes&lt;/code&gt;, and only touches pages whose title matches a sample in the seed file, so anything the user added is left alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Things I was honest about in the docs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Views are marked &lt;code&gt;optional&lt;/code&gt;.&lt;/strong&gt; If Notion rejects a view, the script warns and carries on, because all the data is already there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running the push twice creates a second copy.&lt;/strong&gt; The IDs of everything created are saved to &lt;code&gt;push-result.json&lt;/code&gt;, and the README says to delete the first copy before re-running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The review step is not decoration.&lt;/strong&gt; The README tells you to open &lt;code&gt;schema.json&lt;/code&gt; and &lt;code&gt;layout.md&lt;/code&gt; and edit them. The tool is built around the idea that you read before you push.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would tell my past self
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treat a template as a build artifact. Keep the source (seed data, schema builder, layout text) and generate the rest.&lt;/li&gt;
&lt;li&gt;Put a human-readable review step between generating and sending.&lt;/li&gt;
&lt;li&gt;Restrict what a data file can make your script do.&lt;/li&gt;
&lt;li&gt;Anything that depends on &lt;code&gt;now()&lt;/code&gt; needs a plan for sample data that ages.&lt;/li&gt;
&lt;li&gt;When an API hands out IDs late, design placeholders and capture rules up front.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Talk to me
&lt;/h2&gt;

&lt;p&gt;I build developer tools, automation and full-stack products, and I take freelance projects: &lt;a href="https://mahmoud-farouk-portfolio.vercel.app" rel="noopener noreferrer"&gt;mahmoud-farouk-portfolio.vercel.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you build Notion templates, do you generate them or click them together? I am curious where the API still makes you fall back to the UI.&lt;/p&gt;

</description>
      <category>notion</category>
      <category>javascript</category>
      <category>automation</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Building a multi-tenant POS and inventory system with Spring Boot and Angular: three design decisions that paid off</title>
      <dc:creator>Mahmoud Farouk</dc:creator>
      <pubDate>Sat, 03 Oct 2026 10:33:24 +0000</pubDate>
      <link>https://dev.to/mfarouk2894/building-a-multi-tenant-pos-and-inventory-system-with-spring-boot-and-angular-three-design-end</link>
      <guid>https://dev.to/mfarouk2894/building-a-multi-tenant-pos-and-inventory-system-with-spring-boot-and-angular-three-design-end</guid>
      <description>&lt;p&gt;Small shops need three things from software: sell quickly, know what is in stock and trust the numbers. I built a self-hosted inventory, point-of-sale and purchasing system for them, with Spring Boot 4 (Java 25), Angular 21 and PostgreSQL, running in Docker. It works in English, Arabic (right-to-left) and French, and every shop is an isolated tenant.&lt;/p&gt;

&lt;p&gt;This post is not a feature tour. It covers three design decisions I would make again, with the real code behind each.&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%2Fz6etbwrb4jw5vm5wl9xy.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%2Fz6etbwrb4jw5vm5wl9xy.png" alt="The shop dashboard: sales today, low stock and stock value" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Tenant isolation that nobody can forget to apply
&lt;/h2&gt;

&lt;p&gt;In a multi-tenant system the worst bug is a missing &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt;. One forgotten filter in one repository method and a shop sees another shop's sales.&lt;/p&gt;

&lt;p&gt;So I do not rely on developers remembering. A Hibernate filter is switched on by an aspect before every service method runs:&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;@Aspect&lt;/span&gt;
&lt;span class="nd"&gt;@Component&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;TenantAspect&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@PersistenceContext&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;EntityManager&lt;/span&gt; &lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Before&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"execution(* com.enterprise.starter.kit.modules..service..*(..))"&lt;/span&gt;&lt;span class="o"&gt;)&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;enableTenantFilter&lt;/span&gt;&lt;span class="o"&gt;()&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;tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TenantContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTenantId&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;tenantId&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;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

        &lt;span class="nc"&gt;Session&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unwrap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enableFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tenantFilter"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;setParameter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tenantId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenantId&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 tenant id comes from the authenticated user's JWT and lives in a &lt;code&gt;TenantContext&lt;/code&gt;. Entities that carry the filter definition are restricted automatically; global tables such as subscription plans are skipped.&lt;/p&gt;

&lt;p&gt;What I like about this: the rule is enforced in one place, new modules inherit it for free, and a code reviewer only has to check that an entity declares the filter, not that every query remembers it.&lt;/p&gt;

&lt;p&gt;The trade-off is that it is implicit. Anything that runs outside the service layer (scheduled jobs, raw SQL) does not pass through the aspect, so it needs its own care.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, October 2026:&lt;/strong&gt; one case this filter does not cover. Hibernate filters do not apply to &lt;code&gt;findById&lt;/code&gt;, so 1.1 adds an explicit tenant check on every lookup by id. The write-up of how we found and fixed it goes up on October 28.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2. A stock ledger you never edit directly
&lt;/h2&gt;

&lt;p&gt;The product table has a &lt;code&gt;quantityOnHand&lt;/code&gt; column, and it is tempting to just update it. I did not. Every change goes through an append-only &lt;code&gt;stock_movements&lt;/code&gt; table:&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="cm"&gt;/**
 * Audit ledger of every stock change. Positive quantity = stock in,
 * negative quantity = stock out. Never written directly - only via
 * the catalog adjust-stock, purchase-order receive, and sale-create flows,
 * so it always stays consistent with CatalogItem#getQuantityOnHand().
 */&lt;/span&gt;
&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"stock_movements"&lt;/span&gt;&lt;span class="o"&gt;)&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;StockMovement&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;BaseEntity&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// catalogItem, movementType, quantity (+in / -out),&lt;/span&gt;
    &lt;span class="c1"&gt;// referenceType + referenceId (which sale or purchase order caused it),&lt;/span&gt;
    &lt;span class="c1"&gt;// optional variant id, note, occurredAt&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are only three ways stock moves: a manual adjustment, receiving a purchase order and creating a sale. Each writes a movement that points back to the thing that caused it.&lt;/p&gt;

&lt;p&gt;That gives the shop owner a per-item history ("why is this at 3?") and gives me a debugging tool: when a number looks wrong, the ledger says which sale or purchase order did it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fan7gkqzoqwduqvwfmb6m.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fan7gkqzoqwduqvwfmb6m.gif" alt="A per-item stock ledger: sales, purchases and adjustments" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A checkout that keeps working when the Wi-Fi does not
&lt;/h2&gt;

&lt;p&gt;Shops lose connectivity. A cashier should not have to stop selling. The Angular POS queues cash sales locally and replays them when the browser reports it is online again:&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;STORAGE_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pos_offline_sale_queue&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;FAILED_STORAGE_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pos_offline_sale_failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;online&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="o"&gt;=&amp;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;isOnline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;syncQueue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offline&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="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="nx"&gt;isOnline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&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;A few rules keep this honest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only cash and "other" sales are queued.&lt;/strong&gt; Card payments need a live round trip to create and confirm a payment intent, so they cannot be faked offline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The receipt is a preview.&lt;/strong&gt; The server allocates the real invoice numbers when the sale syncs, so the offline receipt is built from a stored snapshot (totals, tax, line items), not from numbers I invented on the client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failures are never swallowed.&lt;/strong&gt; If the server rejects a queued sale, for example because stock ran out while offline, it moves to a "failed sales" list for the cashier to review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Signals (&lt;code&gt;isOnline&lt;/code&gt;, &lt;code&gt;queue&lt;/code&gt;, &lt;code&gt;syncing&lt;/code&gt;, &lt;code&gt;failedSales&lt;/code&gt;) keep the UI simple: the template just reacts to connectivity, the queue length and the failed list.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F916ll1jb4eq5943rrvua.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F916ll1jb4eq5943rrvua.gif" alt="Selling at the POS: tap products to fill the cart, then check out" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A grounded assistant, without sending data anywhere
&lt;/h2&gt;

&lt;p&gt;The same system has a chat assistant that answers questions such as "which items are low on stock?". It runs on a local model through Ollama, so shop data never goes to a third-party AI service.&lt;/p&gt;

&lt;p&gt;Two choices make a small local model usable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Each question is sent with a fresh snapshot of that tenant's data, through the same tenant filter as every other screen.&lt;/li&gt;
&lt;li&gt;The snapshot starts with a summary and every product carries a precomputed &lt;code&gt;OK&lt;/code&gt; or &lt;code&gt;LOW&lt;/code&gt; status, so the model never has to compare numbers itself. The prompt tells it to answer only from the snapshot and to say so when the answer is not there.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The assistant is read-only: it cannot sell, adjust stock or place orders.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell my past self
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Put the safety rule (tenant filter) where it cannot be skipped, not in a coding guideline.&lt;/li&gt;
&lt;li&gt;Treat stock as a ledger, not a number.&lt;/li&gt;
&lt;li&gt;Design the offline path around what cannot work offline, and be explicit about it.&lt;/li&gt;
&lt;li&gt;Small local models need pre-digested data, not raw tables.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it or talk to me
&lt;/h2&gt;

&lt;p&gt;Live demo: &lt;a href="https://inventory-shop-management-production.up.railway.app" rel="noopener noreferrer"&gt;https://inventory-shop-management-production.up.railway.app&lt;/a&gt; (sign in as &lt;code&gt;shop.admin&lt;/code&gt; / &lt;code&gt;demo123&lt;/code&gt;; card payments are simulated).&lt;/p&gt;

&lt;p&gt;The system is part of a larger Spring Boot and Angular SaaS starter I maintain (the Arab Enterprise Kit). I also take freelance full-stack, backend and architecture projects: &lt;a href="https://mahmoud-farouk-portfolio.vercel.app" rel="noopener noreferrer"&gt;mahmoud-farouk-portfolio.vercel.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What would you do differently for the offline queue? I am curious how others handle replay conflicts.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>angular</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Stop Starting with Microservices: Why I Built a Modular Monolith for my B2B SaaS Boilerplate</title>
      <dc:creator>Mahmoud Farouk</dc:creator>
      <pubDate>Sun, 12 Apr 2026 22:46:28 +0000</pubDate>
      <link>https://dev.to/mfarouk2894/stop-starting-with-microservices-why-i-built-a-modular-monolith-for-my-b2b-saas-boilerplate-4gho</link>
      <guid>https://dev.to/mfarouk2894/stop-starting-with-microservices-why-i-built-a-modular-monolith-for-my-b2b-saas-boilerplate-4gho</guid>
      <description>&lt;p&gt;If you've ever started a new B2B SaaS project, you know the drill. Before you write a single line of feature code that actually brings value to your users, you spend weeks (or months) wrestling with the exact same infrastructure:&lt;/p&gt;

&lt;p&gt;Setting up Multi-tenancy (and worrying about data leakage).&lt;/p&gt;

&lt;p&gt;Configuring stateless JWT authentication.&lt;/p&gt;

&lt;p&gt;Handling database migrations.&lt;/p&gt;

&lt;p&gt;Integrating payment webhooks safely (idempotency, anyone?).&lt;/p&gt;

&lt;p&gt;As a Principal Software Developer, I realized this "setup fatigue" was killing momentum. So, I decided to build the ultimate boilerplate to solve this once and for all: the Arab Enterprise Kit (AEK).&lt;/p&gt;

&lt;p&gt;But when it came to the architecture, I made a controversial choice for 2026: I completely ignored Microservices.&lt;/p&gt;

&lt;p&gt;Here is why I went with a strict Modular Monolith instead, and how I structured the tech stack.&lt;/p&gt;

&lt;p&gt;The Microservices Trap for Early-Stage SaaS&lt;br&gt;
Microservices are fantastic for scaling massive engineering teams. But for solo founders or small teams launching a B2B SaaS, they introduce a DevOps nightmare:&lt;/p&gt;

&lt;p&gt;Distributed tracing becomes mandatory.&lt;/p&gt;

&lt;p&gt;Data consistency and distributed transactions (Saga patterns) slow down feature delivery.&lt;/p&gt;

&lt;p&gt;Infrastructure costs spike before you even have your first paying customer.&lt;/p&gt;

&lt;p&gt;The Modular Monolith Solution&lt;br&gt;
Instead of splitting by network boundaries, I split by logical boundaries within the same deployment unit.&lt;/p&gt;

&lt;p&gt;Here is the tech stack I chose for the AEK:&lt;/p&gt;

&lt;p&gt;Backend: Java 25 &amp;amp; Spring Boot 4. We use Single-Schema Multi-Tenancy with Hibernate Filters. It ensures strict data isolation at the ORM level without the overhead of spinning up a new database per tenant.&lt;/p&gt;

&lt;p&gt;Frontend: Angular 21 (Zoneless UI using Signals). Combined with native Tailwind RTL/LTR support, making it perfectly suited for global and MENA region applications.&lt;/p&gt;

&lt;p&gt;Integrations: * Stripe Webhooks: Fully configured with idempotency keys to prevent double-charging during network retries.&lt;/p&gt;

&lt;p&gt;Local AI (Ollama): Because B2B enterprise clients care deeply about privacy, sending data to 3rd party LLMs is often a dealbreaker. Integrating local AI ensures zero data leaks.&lt;/p&gt;

&lt;p&gt;The Result?&lt;br&gt;
By keeping everything in a well-structured monolith, you get the deployment simplicity of a single .jar file, but the clean separation of domains. If one module ever needs to scale independently, extracting it is straightforward because the boundaries are already respected in the code.&lt;/p&gt;

&lt;p&gt;🎁 Get the Full 37-Page Architectural Blueprint&lt;br&gt;
Before I even wrote the code for the AEK, I documented every single architectural decision, tradeoff, and pattern into a comprehensive blueprint.&lt;/p&gt;

&lt;p&gt;If you are a technical founder or developer struggling with the initial architecture of your SaaS, you can grab the complete 37-page Architectural Blueprint for free here:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://mahmoudfarouk28.gumroad.com/l/sdmeap" rel="noopener noreferrer"&gt;https://mahmoudfarouk28.gumroad.com/l/sdmeap&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd love to hear your thoughts. Have you fallen into the microservices trap early on, or are you team a monolith? Let's discuss in the comments! 👇&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>angular</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
