<?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>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;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;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>
