<?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>Forem: Ravindu Gajanayaka</title>
    <description>The latest articles on Forem by Ravindu Gajanayaka (@ravindu2012).</description>
    <link>https://forem.com/ravindu2012</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3811107%2F67a9b4d2-838f-487c-aed7-9b329457bc88.jpeg</url>
      <title>Forem: Ravindu Gajanayaka</title>
      <link>https://forem.com/ravindu2012</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/ravindu2012"/>
    <language>en</language>
    <item>
      <title>How I Designed a Multi-Tenant ERP System That Isolates 100% of Customer Data</title>
      <dc:creator>Ravindu Gajanayaka</dc:creator>
      <pubDate>Sun, 08 Mar 2026 19:18:40 +0000</pubDate>
      <link>https://forem.com/ravindu2012/how-i-designed-a-multi-tenant-erp-system-that-isolates-100-of-customer-data-5865</link>
      <guid>https://forem.com/ravindu2012/how-i-designed-a-multi-tenant-erp-system-that-isolates-100-of-customer-data-5865</guid>
      <description>&lt;h1&gt;
  
  
  How I Designed a Multi-Tenant ERP System That Isolates 100% of Customer Data
&lt;/h1&gt;

&lt;p&gt;When you build a SaaS application where multiple businesses share the same database, one question keeps you up at night: &lt;strong&gt;"What if Company A accidentally sees Company B's data?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I built Retail Smart ERP — an open-source POS and ERP system serving retail shops, restaurants, supermarkets, auto service centers, and dealerships — all from one codebase, one database. Here's how I made sure every tenant's data stays completely isolated, even when a developer makes a mistake.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Imagine this scenario:&lt;/p&gt;

&lt;p&gt;A developer writes a new API route to fetch customer data. They forget to add the tenant filter. Now every business on the platform can see every other business's customers.&lt;/p&gt;

&lt;p&gt;In a traditional multi-tenant app, this is a real risk. Every single database query needs a &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt; clause. Miss one, and you have a data leak.&lt;/p&gt;

&lt;p&gt;I needed something better. Something that protects data even when the application code has bugs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Solution: Three Layers of Defense
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Layer 1: Application-Level Tenant Filtering
&lt;/h3&gt;

&lt;p&gt;Every API route in the system requires authentication, and every authenticated session includes a &lt;code&gt;tenantId&lt;/code&gt;. All database queries filter by this tenant:&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;items&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the standard approach most SaaS apps use. But it's fragile — it depends on every developer remembering to add the filter every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: PostgreSQL Row-Level Security (RLS)
&lt;/h3&gt;

&lt;p&gt;This is where it gets interesting. PostgreSQL has a built-in feature called Row-Level Security that enforces data isolation at the database level.&lt;/p&gt;

&lt;p&gt;Here's how it works:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; Enable RLS on every tenant-scoped table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Create a policy that filters rows automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; Before every query, set the tenant context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'abc-123-tenant-uuid'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, even if a developer writes &lt;code&gt;SELECT * FROM items&lt;/code&gt; with no WHERE clause, PostgreSQL itself will only return rows belonging to the current tenant. The database becomes the safety net.&lt;/p&gt;

&lt;p&gt;I applied this to all 65+ tables in the system. The migration file is over 200 lines of SQL, but it was worth every line.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: Database Role Enforcement
&lt;/h3&gt;

&lt;p&gt;There's a catch with RLS: the PostgreSQL superuser (&lt;code&gt;postgres&lt;/code&gt;) bypasses all RLS policies by default. In production, if your app connects as &lt;code&gt;postgres&lt;/code&gt;, RLS does nothing.&lt;/p&gt;

&lt;p&gt;The fix: create a restricted database role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt; &lt;span class="n"&gt;NOLOGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, inside every transaction, switch to this role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'abc-123-tenant-uuid'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Now RLS is enforced, even though we connected as postgres&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;app_user&lt;/code&gt; role has no login capability — it's purely for RLS enforcement. The application still connects as &lt;code&gt;postgres&lt;/code&gt; for migrations and admin operations, but tenant-scoped queries run under the restricted role.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Implementation
&lt;/h2&gt;

&lt;p&gt;In practice, I wrapped all of this into helper functions that developers use instead of raw database access:&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;// Recommended: Auth + RLS in one call&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withAuthTenant&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;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;db&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="c1"&gt;// RLS automatically filters by tenant&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// For background jobs with a known tenantId&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&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;db&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;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// For admin operations (bypasses RLS)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withoutTenant&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;db&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;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&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;When you call &lt;code&gt;withAuthTenant&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It checks authentication (returns 401 if not logged in)&lt;/li&gt;
&lt;li&gt;Starts a database transaction&lt;/li&gt;
&lt;li&gt;Sets &lt;code&gt;LOCAL ROLE app_user&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Sets &lt;code&gt;LOCAL app.tenant_id&lt;/code&gt; from the session&lt;/li&gt;
&lt;li&gt;Runs your query (RLS is now active)&lt;/li&gt;
&lt;li&gt;Commits the transaction&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A developer using this helper literally cannot access another tenant's data. The database won't allow it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Subdomain Routing: Each Business Gets Its Own URL
&lt;/h2&gt;

&lt;p&gt;Multi-tenancy isn't just about the database. Each business on Retail Smart ERP gets its own subdomain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gajanayaka-auto.retailsmarterp.com  →  Auto service center
marios-pizza.retailsmarterp.com     →  Restaurant
city-mart.retailsmarterp.com        →  Supermarket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Next.js middleware handles routing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Extract subdomain from the &lt;code&gt;Host&lt;/code&gt; header&lt;/li&gt;
&lt;li&gt;Look up the tenant by slug&lt;/li&gt;
&lt;li&gt;Rewrite the URL to &lt;code&gt;/c/[slug]/...&lt;/code&gt; internally&lt;/li&gt;
&lt;li&gt;The layout validates that the logged-in user belongs to this tenant&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reserved subdomains (&lt;code&gt;www&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt;, &lt;code&gt;api&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt;) are blocked from tenant registration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Five Business Types, One Codebase
&lt;/h2&gt;

&lt;p&gt;The trickiest part wasn't the multi-tenancy — it was supporting five completely different business types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Business Type&lt;/th&gt;
&lt;th&gt;Unique Modules&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Retail&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;POS, inventory, barcodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Restaurant&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Kitchen display, floor plan, table reservations, recipes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Supermarket&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Bulk pricing, shelf labels, loyalty programs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auto Service&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Work orders, vehicle tracking, insurance estimates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dealership&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vehicle inventory, test drives, dealership sales&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each tenant has a &lt;code&gt;businessType&lt;/code&gt; field. The UI shows/hides modules based on this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A restaurant sees the Kitchen and Tables menu items&lt;/li&gt;
&lt;li&gt;An auto service center sees Work Orders and Appointments&lt;/li&gt;
&lt;li&gt;A retail shop sees neither&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the core modules — POS, customers, inventory, accounting, HR — are shared across all business types. This means a bug fix in the POS benefits all five business types simultaneously.&lt;/p&gt;




&lt;h2&gt;
  
  
  Real-Time Updates Across Terminals
&lt;/h2&gt;

&lt;p&gt;A POS system needs real-time data. If a cashier at Terminal 1 sells the last unit of an item, Terminal 2 needs to know immediately.&lt;/p&gt;

&lt;p&gt;I built a custom WebSocket server integrated with Next.js:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;API route creates/updates data&lt;/strong&gt; → calls &lt;code&gt;broadcastChange(tenantId, 'item', 'updated', itemId)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket server&lt;/strong&gt; sends the event to all connected clients for that tenant&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client hooks&lt;/strong&gt; (&lt;code&gt;useRealtimeData&lt;/code&gt;) automatically refresh the displayed data&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The WebSocket connections are also tenant-scoped — a client only receives events for their own tenant. This is another layer of data isolation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. RLS is worth the complexity
&lt;/h3&gt;

&lt;p&gt;Setting up Row-Level Security across 65+ tables was tedious. But the peace of mind is invaluable. I never worry about data leaks from a missing WHERE clause.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Helper functions prevent mistakes
&lt;/h3&gt;

&lt;p&gt;By providing &lt;code&gt;withAuthTenant()&lt;/code&gt; as the standard way to query data, I made the secure path also the easiest path. Developers don't need to think about tenant isolation — it just happens.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Start with RLS from day one
&lt;/h3&gt;

&lt;p&gt;I added RLS after the app already had 50+ tables. Retrofitting was painful. If I started over, I'd enable RLS on every table from the first migration.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Test with multiple tenants in development
&lt;/h3&gt;

&lt;p&gt;I keep at least two test tenants in my local database. This catches cross-tenant bugs that you'd never find with a single tenant.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The superuser bypass is a trap
&lt;/h3&gt;

&lt;p&gt;If you don't set up role enforcement, RLS gives you a false sense of security. Always use a non-superuser role for application queries.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;65+ database tables&lt;/strong&gt; with RLS enabled&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 business types&lt;/strong&gt; from one codebase&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;15+ user roles&lt;/strong&gt; with granular permissions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time WebSocket&lt;/strong&gt; updates across all terminals&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subdomain routing&lt;/strong&gt; for tenant isolation at the URL level&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;Retail Smart ERP is fully open source. You can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;See the live demo:&lt;/strong&gt; &lt;a href="https://www.retailsmarterp.com" rel="noopener noreferrer"&gt;retailsmarterp.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the code:&lt;/strong&gt; &lt;a href="https://github.com/ravindu2012/retail-smart-erp" rel="noopener noreferrer"&gt;github.com/ravindu2012/retail-smart-erp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contribute:&lt;/strong&gt; Issues labeled &lt;code&gt;good first issue&lt;/code&gt; are waiting for you&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The RLS migration, tenant context helpers, and WebSocket implementation are all in the repo. Feel free to use the patterns in your own projects.&lt;/p&gt;




&lt;h2&gt;
  
  
  Other Projects
&lt;/h2&gt;

&lt;p&gt;I'm also building:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/ravindu2012/pos-prime" rel="noopener noreferrer"&gt;POS Prime&lt;/a&gt;&lt;/strong&gt; — A modern POS replacement for ERPNext (Vue 3, Python)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/ravindu2012/QuickBooksDesktop" rel="noopener noreferrer"&gt;QuickBooks Desktop Clone&lt;/a&gt;&lt;/strong&gt; — Desktop accounting app (.NET 8, WPF)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All open source, all looking for contributors.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sponsors/ravindu2012" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimg.shields.io%2Fbadge%2FSponsor-%25E2%259D%25A4-red%3Fstyle%3Dfor-the-badge" alt="Sponsor on GitHub" width="116" height="28"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://buymeacoffee.com/ravindu2012" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimg.shields.io%2Fbadge%2FBuy_Me_A_Coffee-FFDD00%3Fstyle%3Dfor-the-badge%26logo%3Dbuy-me-a-coffee%26logoColor%3Dblack" alt="Buy Me A Coffee" width="161" height="28"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Questions about multi-tenant architecture? Drop a comment — happy to go deeper on any of these topics.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>postgressql</category>
      <category>nextjs</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Can't Code, But I Built 3 Open-Source Business Apps — Here's How</title>
      <dc:creator>Ravindu Gajanayaka</dc:creator>
      <pubDate>Sat, 07 Mar 2026 07:15:55 +0000</pubDate>
      <link>https://forem.com/ravindu2012/i-cant-code-but-i-built-3-open-source-business-apps-heres-how-2ca7</link>
      <guid>https://forem.com/ravindu2012/i-cant-code-but-i-built-3-open-source-business-apps-heres-how-2ca7</guid>
      <description>&lt;h1&gt;
  
  
  I Can't Code, But I Built 3 Open-Source Business Apps — Here's How
&lt;/h1&gt;

&lt;p&gt;I don't know Python. I can't write JavaScript from scratch. I've never formally learned any programming language.&lt;/p&gt;

&lt;p&gt;But in the last year, I've built and shipped three open-source business applications that are live, functional, and growing. One of them is a full multi-tenant ERP system with 65+ database tables, real-time WebSocket updates, and AI-powered features.&lt;/p&gt;

&lt;p&gt;This is how I did it.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Background
&lt;/h2&gt;

&lt;p&gt;I'm not a developer. I'm a guy from Sri Lanka who understands business operations — how a retail shop runs, how an auto service center manages work orders, how accounting should flow. I've worked with business software my whole career and always thought: "I could build something better than this."&lt;/p&gt;

&lt;p&gt;The problem was always the same: I had the ideas but not the coding skills.&lt;/p&gt;

&lt;p&gt;Then AI changed everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Approach: AI as My Development Partner
&lt;/h2&gt;

&lt;p&gt;I use AI tools — primarily Claude — as my coding partner. But let me be clear about what that means:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I don't just say "build me an app" and wait.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what I actually do:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I design the system.&lt;/strong&gt; I decide the database schema, the user flows, the business logic. I know &lt;em&gt;what&lt;/em&gt; needs to be built because I understand the domain deeply.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I architect the solution.&lt;/strong&gt; I choose the tech stack, plan the modules, decide how things connect. Multi-tenant architecture with row-level security? That was my decision based on real business needs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I direct every feature.&lt;/strong&gt; I work feature by feature, reviewing every piece of code, testing it, and iterating. If something doesn't work the way a real business needs it to, I know immediately.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AI writes the code.&lt;/strong&gt; I describe what I need, review what it produces, and guide it to fix issues. Think of it like being an architect who doesn't lay bricks but designs the entire building.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I debug through conversation.&lt;/strong&gt; When things break — and they do — I describe the problem and work through solutions. Over time, I've developed an intuition for common issues even without reading the code line by line.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key insight: &lt;strong&gt;domain knowledge is more valuable than syntax knowledge.&lt;/strong&gt; A developer who doesn't understand accounting will build a terrible accounting system, no matter how clean their code is.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Retail Smart ERP
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A multi-tenant SaaS Point of Sale and ERP system.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tech: Next.js 16, React 19, PostgreSQL, WebSocket, Tailwind CSS&lt;/p&gt;

&lt;p&gt;This is the big one. A complete business management system that supports five different business types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Retail shops&lt;/li&gt;
&lt;li&gt;Restaurants (with kitchen display and floor plans)&lt;/li&gt;
&lt;li&gt;Supermarkets&lt;/li&gt;
&lt;li&gt;Auto service centers (with work orders and vehicle tracking)&lt;/li&gt;
&lt;li&gt;Vehicle dealerships&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Features that I'm particularly proud of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenant with subdomain routing&lt;/strong&gt; — each business gets &lt;code&gt;company.retailsmarterp.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Row-level security&lt;/strong&gt; — tenant data isolation at the database level, not just application level&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time updates&lt;/strong&gt; — WebSocket-powered, so every terminal sees changes instantly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI-powered insights&lt;/strong&gt; — smart warnings and anomaly detection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;65+ database tables&lt;/strong&gt; with proper double-entry accounting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://www.retailsmarterp.com" rel="noopener noreferrer"&gt;retailsmarterp.com&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ravindu2012/retail-smart-erp" rel="noopener noreferrer"&gt;github.com/ravindu2012/retail-smart-erp&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. POS Prime
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A modern Point of Sale replacement for ERPNext.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tech: Vue 3, TypeScript, Python, Frappe Framework, Tailwind CSS&lt;/p&gt;

&lt;p&gt;ERPNext is a popular open-source ERP, but its built-in POS is slow and clunky. I built POS Prime as a complete replacement that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Works on touch screens and barcode scanners&lt;/li&gt;
&lt;li&gt;Has a self-checkout kiosk mode&lt;/li&gt;
&lt;li&gt;Supports customer pole displays&lt;/li&gt;
&lt;li&gt;Handles split payments and returns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero modifications to ERPNext&lt;/strong&gt; — installs cleanly, uninstalls cleanly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "zero modifications" part was a deliberate design decision. Too many ERPNext apps break upgrades because they add custom fields everywhere. POS Prime works entirely with standard ERPNext doctypes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ravindu2012/pos-prime" rel="noopener noreferrer"&gt;github.com/ravindu2012/pos-prime&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. QuickBooks Desktop Clone
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A full-featured desktop accounting application.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tech: .NET 8, WPF, C#, Clean Architecture&lt;/p&gt;

&lt;p&gt;This one targets small businesses that need proper accounting software but can't afford QuickBooks. Features include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Double-entry accounting engine with 11 transaction posting types&lt;/li&gt;
&lt;li&gt;65+ seeded Chart of Accounts&lt;/li&gt;
&lt;li&gt;Customer and vendor management with aging reports&lt;/li&gt;
&lt;li&gt;Invoice, bill, and payment processing&lt;/li&gt;
&lt;li&gt;Banking and reconciliation&lt;/li&gt;
&lt;li&gt;Full financial reporting (P&amp;amp;L, Balance Sheet, Trial Balance)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ravindu2012/QuickBooksDesktop" rel="noopener noreferrer"&gt;github.com/ravindu2012/QuickBooksDesktop&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I've Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. You don't need to know code to build software
&lt;/h3&gt;

&lt;p&gt;You need to know what the software should &lt;em&gt;do&lt;/em&gt;. The actual typing of code is becoming less important every day. Understanding user needs, business logic, data relationships, and system architecture — that's what matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. AI is a tool, not a replacement for thinking
&lt;/h3&gt;

&lt;p&gt;AI can write code, but it can't decide what to build. It can't tell you that auto service centers need to track core returns separately from regular parts. It can't tell you that restaurant POS needs a different flow than retail POS. Domain expertise is irreplaceable.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Start with what you know
&lt;/h3&gt;

&lt;p&gt;All three of my projects come from domains I understand deeply. I didn't try to build a social media app or a game. I built business tools because that's where my knowledge lives.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Ship early, iterate often
&lt;/h3&gt;

&lt;p&gt;My first version of Retail Smart ERP was embarrassingly basic. But it worked. Each week I added features, fixed issues, and improved the experience. Shipping beats perfection.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Open source amplifies everything
&lt;/h3&gt;

&lt;p&gt;Making my projects open source was the best decision I made. It forced me to write better documentation, think about contributor experience, and build things that work for more than just me.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Uncomfortable Truth
&lt;/h2&gt;

&lt;p&gt;Some developers might read this and think it's "cheating" or "not real development." I understand that reaction.&lt;/p&gt;

&lt;p&gt;But consider this: the end user doesn't care if a human typed every semicolon or if AI helped. They care that the POS system processes their sales correctly, that the accounting balances, and that their business data is secure.&lt;/p&gt;

&lt;p&gt;I'm not claiming to be a senior developer. I'm a product builder who uses the best tools available. Five years ago, that might have meant hiring a team. Today, it means partnering with AI.&lt;/p&gt;

&lt;p&gt;The barrier to building software has dropped dramatically. If you have deep knowledge in any field — healthcare, education, logistics, finance, anything — you can now turn that knowledge into real software.&lt;/p&gt;




&lt;h2&gt;
  
  
  Want to Contribute?
&lt;/h2&gt;

&lt;p&gt;All three projects are open source and actively looking for contributors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/ravindu2012/retail-smart-erp" rel="noopener noreferrer"&gt;Retail Smart ERP&lt;/a&gt;&lt;/strong&gt; — Next.js, React, PostgreSQL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/ravindu2012/pos-prime" rel="noopener noreferrer"&gt;POS Prime&lt;/a&gt;&lt;/strong&gt; — Vue 3, Python, ERPNext&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/ravindu2012/QuickBooksDesktop" rel="noopener noreferrer"&gt;QuickBooks Desktop Clone&lt;/a&gt;&lt;/strong&gt; — .NET 8, WPF, C#&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each repo has &lt;code&gt;good first issue&lt;/code&gt; labels, contributing guides, and active discussions. Whether you're a seasoned developer or someone like me who's learning as they go — you're welcome.&lt;/p&gt;

&lt;p&gt;If this project helps you or your business, consider supporting development:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sponsors/ravindu2012" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimg.shields.io%2Fbadge%2FSponsor-%25E2%259D%25A4-red%3Fstyle%3Dfor-the-badge" alt="Sponsor on GitHub" width="116" height="28"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://buymeacoffee.com/ravindu2012" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimg.shields.io%2Fbadge%2FBuy_Me_A_Coffee-FFDD00%3Fstyle%3Dfor-the-badge%26logo%3Dbuy-me-a-coffee%26logoColor%3Dblack" alt="Buy Me A Coffee" width="161" height="28"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have questions? Drop a comment below or find me on &lt;a href="https://github.com/ravindu2012" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>beginners</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
