<?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: Tiani pekins Ebika</title>
    <description>The latest articles on DEV Community by Tiani pekins Ebika (@tianipekinsebika).</description>
    <link>https://dev.to/tianipekinsebika</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%2F3909522%2Fad200e25-3855-4932-b852-06d60a7c842a.jpeg</url>
      <title>DEV Community: Tiani pekins Ebika</title>
      <link>https://dev.to/tianipekinsebika</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tianipekinsebika"/>
    <language>en</language>
    <item>
      <title>The 3-Tier Architecture of LocalHands: Engineering a Scalable Service Marketplace</title>
      <dc:creator>Tiani pekins Ebika</dc:creator>
      <pubDate>Mon, 22 Jun 2026 19:14:27 +0000</pubDate>
      <link>https://dev.to/tianipekinsebika/the-3-tier-architecture-of-localhands-engineering-a-scalable-service-marketplace-1idm</link>
      <guid>https://dev.to/tianipekinsebika/the-3-tier-architecture-of-localhands-engineering-a-scalable-service-marketplace-1idm</guid>
      <description>&lt;p&gt;&lt;strong&gt;Subtitle: How a split frontend/backend, a NestJS API layer, and a strict relational core hold up under fragmented infrastructure.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With the Prisma schema in place, the next question was what serves it. Below, I break down the three-tier architecture LocalHands runs on: how the layers are split and what each one is responsible for.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why 3-Tiers, Two Repositories
&lt;/h2&gt;

&lt;p&gt;LocalHands runs as two independent codebases, frontend and backend, each with its own repo, deployment pipeline, and release cycle. They talk over a REST API.&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%2F0urde8l1ks7rr89l9tsv.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%2F0urde8l1ks7rr89l9tsv.png" alt=" " width="800" height="566"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;Figure 1:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;Client, server, and external service layers, and how they connect.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The diagram states the same split as a request path:&lt;br&gt;
&lt;code&gt;browser&lt;/code&gt; → &lt;code&gt;React over HTTPS&lt;/code&gt; → &lt;code&gt;NestJS over REST&lt;/code&gt; → &lt;code&gt;Prisma&lt;/code&gt; → &lt;code&gt;PostgreSQL&lt;/code&gt;, pooling connections rather than opening one per request. Fapshi and Google OAuth sit outside that backend boundary as their own External Services layer dependencies that LocalHands doesn't control, so they fail and rate-limit on their own schedule.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;This split is a direct response to a specific set of non-functional requirements defined early in the project:&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;NFR&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Availability&lt;/td&gt;
&lt;td&gt;99.9% uptime, minimal downtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Performance&lt;/td&gt;
&lt;td&gt;Fast page loads, responsive interaction even under concurrent load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;HTTPS/TLS on all communication; passwords and financial details hashed/encrypted at rest and in transit; protection against SQL injection and XSS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scalability&lt;/td&gt;
&lt;td&gt;Handle growing users, services, and transactions without performance degradation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Table 1:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;Non-functional requirements driving the architecture.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineering Decision:&lt;/strong&gt; Splitting frontend and backend into separate repos means either layer can be redeployed, scaled, or swapped without touching the other.&lt;br&gt;
 Given fragmented connectivity and variable device quality across the target market, this isolation matters more than it would in a single-region SaaS product.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  2. Tier 1-Frontend: React 19 + Vite
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"dependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"react"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^19.1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;UI&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;library&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"react-dom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^19.1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;DOM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;renderer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;React&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"antd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^5.26.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;             &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;component&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;library&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(tables&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;modals)&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"axios"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^1.10.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;client&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;backend&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;API&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"react-router-dom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^7.6.2"&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;client-side&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;routing&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"devDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@tailwindcss/vite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^4.3.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;utility&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;CSS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;wired&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;into&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Vite&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;build&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^6.2.0"&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;dev&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;server&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;production&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;bundler&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;A Single-Page Application:&lt;/strong&gt; React 19, built with Vite, styled with Ant Design + Tailwind v4, deployed on Vercel. The full reasoning behind each tool choice-why React over alternatives, why Ant Design, why Vite-is its own post; what matters architecturally here is how the pieces fit together.&lt;/p&gt;

&lt;p&gt;The app is routed by role, not just by path:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Prefix&lt;/th&gt;
&lt;th&gt;Auth Required&lt;/th&gt;
&lt;th&gt;Layout&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Public&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;MainLayout&lt;/code&gt; [Header + Footer]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/client/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes (CLIENT)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ClientLayout&lt;/code&gt; [Sidebar]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/provider/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes (PROVIDER)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ProviderLayout&lt;/code&gt; [Sidebar]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/admin/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes (ADMIN)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AdminLayout&lt;/code&gt; [Sidebar]&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Table 2:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;Route prefixes, access rules, and layout per role&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Auth state isn't passed manually on every request these two interceptors handle it once, globally:&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;// Axios interceptors (utils/api.ts)&lt;/span&gt;
&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;attach&lt;/span&gt; &lt;span class="nx"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Bearer&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;  &lt;span class="c1"&gt;// every outgoing call is authenticated automatically&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;on&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;clear&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;show&lt;/span&gt; &lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;redirect&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt;    &lt;span class="c1"&gt;// session expiry handled in one place, not per-component&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineering Decision:&lt;/strong&gt; &lt;code&gt;HashRouter&lt;/code&gt; over &lt;code&gt;BrowserRouter&lt;/code&gt; was a deliberate trade it means client-side routing works correctly on static hosting (Vercel) without needing server-side rewrite rules.&lt;br&gt;
 SPA means one initial load, then only data, not full pages, moves over the wire afterward, which matters directly for users on a constrained connection.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3. Tier 2-API Layer: NestJS
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The backend is a NestJS application sitting in front of PostgreSQL via Prisma, with Passport.js/JWT handling auth and class-validator/class-transformer handling DTO validation. &lt;/li&gt;
&lt;li&gt;Swagger UI documents the API live at &lt;code&gt;/api/docs&lt;/code&gt;. Why each of these over the alternatives is covered in the stack-and-tools post here, the focus is on what they let the architecture do.&lt;/li&gt;
&lt;/ul&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%2Fkeckdsow3w9ha59jy94u.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%2Fkeckdsow3w9ha59jy94u.png" alt=" " width="800" height="630"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;Figure 2:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;Component hierarchy detailing child module communication with the root App&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The backend runs &lt;strong&gt;23 feature modules&lt;/strong&gt; off a single root &lt;code&gt;AppModule&lt;/code&gt;:&lt;br&gt;
Grouped by domain, not left flat:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Domain&lt;/th&gt;
&lt;th&gt;Modules&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Config,&lt;/code&gt; &lt;code&gt;Prisma,&lt;/code&gt; &lt;code&gt;Common,&lt;/code&gt; &lt;code&gt;Healthcheck&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Auth,&lt;/code&gt; &lt;code&gt;User,&lt;/code&gt; &lt;code&gt;Provider,&lt;/code&gt; &lt;code&gt;Client,&lt;/code&gt; &lt;code&gt;Profile&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marketplace&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Service,&lt;/code&gt; &lt;code&gt;ServicePackage,&lt;/code&gt; &lt;code&gt;ServiceAsset,&lt;/code&gt; &lt;code&gt;ServiceOrder,&lt;/code&gt; &lt;code&gt;Category&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engagement&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Booking,&lt;/code&gt; &lt;code&gt;Availability,&lt;/code&gt; &lt;code&gt;Proposal,&lt;/code&gt; &lt;code&gt;Review&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Money&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Contract,&lt;/code&gt; &lt;code&gt;Payment&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comms&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Messages,&lt;/code&gt; &lt;code&gt;Notifications&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Settings&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Table 3:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;The 23 feature modules, grouped by domain.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every request follows the same pipeline before it reaches business logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request
  → CORS                        // only whitelisted origins allowed
  → Compression                 // gzip the response body
  → Global ValidationPipe       // strip/reject/transform the payload
  → JwtAuthGuard (route-level)  // only runs on protected routes
  → Controller                  // receives the validated request
  → Service                     // business logic lives here
  → Prisma                      // typed query builder
  → PostgreSQL                  // data actually persists here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2F4dsiv8lcgmivs4fdh30h.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%2F4dsiv8lcgmivs4fdh30h.png" alt=" " width="800" height="1683"&gt;&lt;/a&gt;&lt;br&gt;
                     &lt;strong&gt;&lt;em&gt;Figure 3:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;Request-pipeline&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// main.ts-ValidationPipe config&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;whitelist&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;// strip unknown fields&lt;/span&gt;
  &lt;span class="nx"&gt;transform&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;// cast payloads to DTO types&lt;/span&gt;
  &lt;span class="nx"&gt;forbidNonWhitelisted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;// reject unexpected fields outright&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two flows worth detailing since they cross more than one module:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flow&lt;/th&gt;
&lt;th&gt;Steps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Provider verification&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;verificationStatus&lt;/code&gt; starts &lt;code&gt;PENDING&lt;/code&gt; → Provider uploads ID doc via Profile module → Admin reviews manually → status set to &lt;code&gt;VERIFIED&lt;/code&gt; or &lt;code&gt;REJECTED&lt;/code&gt;. Only &lt;code&gt;VERIFIED&lt;/code&gt; providers can submit Proposals-enforced server-side.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Authentication&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST /api/auth/login&lt;/code&gt; → &lt;code&gt;AuthService.validateUser()&lt;/code&gt; → lookup by email/phone → &lt;code&gt;bcrypt.compare()&lt;/code&gt; against stored hash → JWT signed (&lt;code&gt;email&lt;/code&gt;, &lt;code&gt;phoneNumber&lt;/code&gt;, &lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;role&lt;/code&gt;, 1h expiry) → returned to client.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Table 4:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;Two flows that cross multiple modules.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What gets signed and carried on every request after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JWT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;payload&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;structure&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;one&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;two&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;lookup&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;fields&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;login&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phoneNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+237..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;other&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;lookup&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;field&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;phone-first&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;identity&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;user id&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;standard&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JWT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;subject&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;claim&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;                 &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;included&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;so&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;UI&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;can&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;render&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;it&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;without&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;refetch&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CLIENT | PROVIDER | ADMIN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;drives&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;both&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;UI&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;routing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;RoleGuard&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;checks&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;issued + 1h&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;short-lived&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;purpose&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineering Decision:&lt;/strong&gt; Each module is internally isolated Payment logic doesn't reach into Contract internals, Review doesn't know how Proposals work. They aren't separate deployable services yet; that split is possible later precisely because NestJS enforces these boundaries now.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  4. Tier 3-Data Layer: PostgreSQL + Prisma
&lt;/h2&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%2F4phjhu3yi8sxg0dt2zz4.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%2F4phjhu3yi8sxg0dt2zz4.png" alt=" " width="657" height="1240"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;Figure 4:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;How a query travels from the service layer down to the database.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each box in the diagram is a real step a query takes, not just a simplified picture. A Service first calls &lt;code&gt;PrismaService&lt;/code&gt;. &lt;code&gt;PrismaService&lt;/code&gt; doesn't talk to &lt;code&gt;PostgreSQL&lt;/code&gt; directly, it goes through &lt;code&gt;@prisma/adapter-pg&lt;/code&gt;, an adapter that lets Prisma run its queries through the standard node-postgres library.&lt;/li&gt;
&lt;li&gt; From there, the query goes into a &lt;strong&gt;connection pool:&lt;/strong&gt; a small set of database connections that stay open and get reused, instead of opening a brand new connection for every single request.&lt;/li&gt;
&lt;li&gt;That pooling matters most under load. Without it, a burst of requests at the same time would mean a burst of new database connections, and that's exactly the kind of thing that can quietly overwhelm a small Postgres instance.&lt;/li&gt;
&lt;li&gt;The full database design, the User/Profile split, the 
&lt;code&gt;ServiceOrder&lt;/code&gt; → &lt;code&gt;Proposal&lt;/code&gt; → &lt;code&gt;Contract lifecycle,&lt;/code&gt; the rule that blocks double payment was already covered in the previous post. A few extra tables from the full schema didn't make it into that article and are worth covering here:&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Availability&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Provider working hours per &lt;code&gt;dayOfWeek&lt;/code&gt;; checked before a direct Booking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ServicePackage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bundled Service offerings tied to a Provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ServiceAsset&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Media attached to a Service listing, typed via &lt;code&gt;AssetType&lt;/code&gt;- the portfolio/visual-proof mechanism the field research called for&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Message&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;senderId&lt;/code&gt; / &lt;code&gt;receiverId&lt;/code&gt; pairs for in-app Client–Provider comms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Notification&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Typed, per-user records (&lt;code&gt;message&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;read&lt;/code&gt;) tied to events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SystemSettings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Platform config: maintenance mode, registration toggles, review auto-approval, currency, support email&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Table 5:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;Supporting models not covered in the schema deep-dive post.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

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

enum PaymentMethod {
  MTN_MOBILE_MONEY  // dominant rail, shipped first
  ORANGE_MONEY      // added once the escrow logic around the first rail was proven
  BANK_TRANSFER     // smaller share of volume, but real not assumed away
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MTN MoMo and Orange Money both see active daily use in this market, and a smaller but real share of transactions still move via bank transfer. Encoding all three as first-class values rather than assuming one dominant rail keeps the schema honest about the payment landscape it actually serves.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Engineering Decision:&lt;/strong&gt; The enum started narrow, a single, well-tested rail, and was widened to all three once the escrow logic around it was proven. Adding a &lt;code&gt;PaymentMethod&lt;/code&gt; value is a migration, not a redesign, which is what made that expansion low-risk.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;How the Payment module actually talks to Fapshi, the controller-to-gateway chain, and the mobile money handoff is worth its own walkthrough rather than a footnote here. That's coming in the Escrow Algorithm post.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Request Flow
&lt;/h2&gt;

&lt;p&gt;Login, end to end, is the clearest illustration of the full stack working together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. User submits identifier + password           // Frontend
2. POST /api/auth/login                          // Frontend → API
3. AuthController → AuthService.validateUser()   // API
4. UserService looks up user by email/phone      // API → Prisma
5. bcrypt.compare(password, storedHash)          // API
6. Success → sign JWT, update lastLogin          // API → DB
   Failure → ForbiddenException → 401            // API → Frontend
7. Frontend stores token, redirects to dashboard // Frontend
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same pattern repeats for every other action on the platform-a Client posting a &lt;code&gt;Service Order&lt;/code&gt;, a Provider submitting a &lt;code&gt;Proposal&lt;/code&gt;, a &lt;code&gt;Contract&lt;/code&gt; being accepted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Frontend request → Controller validation → Service logic →
Prisma → PostgreSQL → Response → UI update
// same six-hop cycle regardless of which module handles the request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No full page reload, no server-rendered round trip-just a tight, predictable cycle through the same three layers every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Why This, Not Something Fancier
&lt;/h2&gt;

&lt;p&gt;This is a conventional 3-tier system on purpose. No microservices, no event bus, no server-side rendering. The non-functional requirements driving it are concrete, not aspirational marketing language:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Commitment&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Encrypted transport&lt;/td&gt;
&lt;td&gt;HTTPS/TLS on every connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password safety&lt;/td&gt;
&lt;td&gt;bcrypt hashing; never returned in API responses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Injection protection&lt;/td&gt;
&lt;td&gt;Prisma parameterizes every query by construction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure visibility&lt;/td&gt;
&lt;td&gt;Global exception filter + structured, sanitized logging (passwords/tokens redacted before write)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Horizontal scaling&lt;/td&gt;
&lt;td&gt;Stateless backend modules can scale out, or split into services later, without a redesign&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Table 6:&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;Security and scalability commitments behind the architecture.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Given the real constraints, inconsistent connectivity, budget devices, a payment layer that has to work over mobile money, a well-bounded, boring architecture is what makes the harder problem (trust) solvable on top of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next post:&lt;/strong&gt;&lt;br&gt;
 &lt;strong&gt;Access control:&lt;/strong&gt; how LocalHands distinguishes &lt;code&gt;Clients&lt;/code&gt;, &lt;code&gt;Providers&lt;/code&gt; and &lt;code&gt;Admins&lt;/code&gt;, and how every request crossing this architecture gets authenticated and authorized.&lt;/p&gt;




</description>
      <category>programming</category>
      <category>opensource</category>
      <category>architecture</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Architecting Digital Trust: A Relational Deep Dive into the LocalHands Prisma Schema</title>
      <dc:creator>Tiani pekins Ebika</dc:creator>
      <pubDate>Sat, 02 May 2026 21:10:12 +0000</pubDate>
      <link>https://dev.to/tianipekinsebika/architecting-digital-trust-a-relational-deep-dive-into-the-localhands-prisma-schema-12dk</link>
      <guid>https://dev.to/tianipekinsebika/architecting-digital-trust-a-relational-deep-dive-into-the-localhands-prisma-schema-12dk</guid>
      <description>&lt;p&gt;&lt;strong&gt;Subtitle: How to model a secure, escrow-based marketplace for emerging economies using Prisma and PostgreSQL.&lt;br&gt;
Schema.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In my previous article on Medium, I discussed the sociotechnical challenge of Information Poverty in the African gig economy. But as engineers, we know that solving social problems requires more than vision it requires a robust, type-safe, and scalable data architecture.&lt;br&gt;
For LocalHands, I chose Prisma ORM with PostgreSQL. The goal was to build a "Technical Source of Truth" that could handle the complexity of service listings, competitive bidding (proposals), and secure escrow payments.&lt;br&gt;
Below, I break down the core relational logic of the LocalHands schema&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The Core Actor Model: User vs. Profile&lt;/strong&gt;&lt;br&gt;
In a marketplace, users often play multiple roles. However, security is paramount. I separated the User (authentication and roles) from the Profile (sensitive KYC data)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;model&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;id&lt;/span&gt;               &lt;span class="nx"&gt;Int&lt;/span&gt;              &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;id&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;autoincrement&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;             &lt;span class="nx"&gt;UserRole&lt;/span&gt;         &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CLIENT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;phoneNumber&lt;/span&gt;      &lt;span class="nb"&gt;String&lt;/span&gt;           &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;unique&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt;            &lt;span class="nb"&gt;String&lt;/span&gt;           &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;unique&lt;/span&gt;
  &lt;span class="nx"&gt;passwordHash&lt;/span&gt;     &lt;span class="nb"&gt;String&lt;/span&gt;
  &lt;span class="nx"&gt;profile&lt;/span&gt;          &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
  &lt;span class="c1"&gt;// ... relations to orders, contracts, and services&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;id&lt;/span&gt;                 &lt;span class="nx"&gt;Int&lt;/span&gt;              &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;id&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;autoincrement&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="nx"&gt;userId&lt;/span&gt;             &lt;span class="nx"&gt;Int&lt;/span&gt;              &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;unique&lt;/span&gt;
  &lt;span class="nx"&gt;user&lt;/span&gt;               &lt;span class="nx"&gt;User&lt;/span&gt;             &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;relation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;references&lt;/span&gt;&lt;span class="p"&gt;:&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="nx"&gt;verificationStatus&lt;/span&gt; &lt;span class="nx"&gt;VerificationStatus&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PENDING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;nationalIdUrl&lt;/span&gt;      &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;          &lt;span class="c1"&gt;// URL to encrypted storage&lt;/span&gt;
  &lt;span class="nx"&gt;mobileMoneyNumber&lt;/span&gt;  &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Engineering Decision:&lt;/strong&gt; By using a 1:1 relation for the Profile, we keep the User model lean for frequent authentication checks while isolating heavier metadata and verification documents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Modeling the Bidding Lifecycle (Service -&amp;gt; Order -&amp;gt; Proposal)&lt;/strong&gt;&lt;br&gt;
Unlike standard e-commerce, a service marketplace is dynamic. A client doesn't just "buy"; they post a &lt;strong&gt;ServiceOrder&lt;/strong&gt;, and providers reply with &lt;strong&gt;Proposals&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="nx"&gt;ServiceOrder&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;id&lt;/span&gt;            &lt;span class="nx"&gt;Int&lt;/span&gt;              &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;id&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;autoincrement&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="nx"&gt;serviceId&lt;/span&gt;     &lt;span class="nx"&gt;Int&lt;/span&gt;
  &lt;span class="nx"&gt;clientId&lt;/span&gt;      &lt;span class="nx"&gt;Int&lt;/span&gt;
  &lt;span class="nx"&gt;budget&lt;/span&gt;        &lt;span class="nx"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt;        &lt;span class="nx"&gt;ServiceOrderStatus&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PENDING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;contract&lt;/span&gt;      &lt;span class="nx"&gt;Contract&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;          &lt;span class="c1"&gt;// Only exists once a proposal is accepted&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="nx"&gt;Proposal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;id&lt;/span&gt;           &lt;span class="nx"&gt;Int&lt;/span&gt;          &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;id&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;autoincrement&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="nx"&gt;providerId&lt;/span&gt;   &lt;span class="nx"&gt;Int&lt;/span&gt;
  &lt;span class="nx"&gt;serviceId&lt;/span&gt;    &lt;span class="nx"&gt;Int&lt;/span&gt;
  &lt;span class="nx"&gt;bidAmount&lt;/span&gt;    &lt;span class="nx"&gt;Float&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt;       &lt;span class="nx"&gt;ProposalStatus&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PENDING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;contractId&lt;/span&gt;   &lt;span class="nx"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Relational Integrity:&lt;/strong&gt; Notice the optional contractId in the Proposal. This allows multiple providers to bid on one job, but ensures that only the &lt;strong&gt;accepted&lt;/strong&gt; proposal transitions into a formal, binding Contract.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The Trust Engine: Contract and Escrow&lt;/strong&gt;&lt;br&gt;
This is where the code solves the &lt;strong&gt;Trust Gap&lt;/strong&gt;. The Contract model acts as the central node for the entire transaction lifecycle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="nx"&gt;Contract&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;id&lt;/span&gt;            &lt;span class="nx"&gt;Int&lt;/span&gt;              &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;id&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;autoincrement&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="nx"&gt;serviceOrderId&lt;/span&gt; &lt;span class="nx"&gt;Int&lt;/span&gt;              &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;unique&lt;/span&gt;
  &lt;span class="nx"&gt;escrowAmount&lt;/span&gt;  &lt;span class="nx"&gt;Float&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt;        &lt;span class="nx"&gt;ContractStatus&lt;/span&gt;   &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;payments&lt;/span&gt;      &lt;span class="nx"&gt;Payment&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="nx"&gt;reviews&lt;/span&gt;       &lt;span class="nx"&gt;Review&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;By enforcing a &lt;a class="mentioned-user" href="https://dev.to/unique"&gt;@unique&lt;/a&gt; constraint on the serviceOrderId, we prevent the "Double-Payment" bug. The contract is the only entity authorized to trigger a Payment release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Localized FinTech Integration&lt;/strong&gt;&lt;br&gt;
To meet the reality of the Cameroonian market, the schema explicitly supports MTN Mobile Money and localized currency settings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="nx"&gt;Payment&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;id&lt;/span&gt;            &lt;span class="nx"&gt;Int&lt;/span&gt;             &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;id&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;autoincrement&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="nx"&gt;contractId&lt;/span&gt;    &lt;span class="nx"&gt;Int&lt;/span&gt;
  &lt;span class="nx"&gt;amount&lt;/span&gt;        &lt;span class="nx"&gt;Float&lt;/span&gt;
  &lt;span class="nx"&gt;paymentMethod&lt;/span&gt; &lt;span class="nx"&gt;PaymentMethod&lt;/span&gt;   &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MTN_MOBILE_MONEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt;        &lt;span class="nx"&gt;PaymentStatus&lt;/span&gt;   &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PENDING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="nx"&gt;SystemSettings&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;currency&lt;/span&gt;           &lt;span class="nb"&gt;String&lt;/span&gt;   &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;XAF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;currency_symbol&lt;/span&gt;    &lt;span class="nb"&gt;String&lt;/span&gt;   &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FCFA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;payment_gateway&lt;/span&gt;     &lt;span class="nb"&gt;String&lt;/span&gt;   &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fapshi&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt; Hardcoding these enums and settings at the database level ensures that the business logic remains consistent and compliant with regional financial regulations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;br&gt;
This schema is designed to do more than just store data; it is designed to &lt;strong&gt;enforce trust&lt;/strong&gt;. By leveraging Prisma's powerful relational features, I have built a foundation where Information Poverty is replaced by a transparent, verifiable history of service.&lt;br&gt;
&lt;strong&gt;What’s Next?&lt;/strong&gt;&lt;br&gt;
Currently, I am architecting the system layers that will host this schema in production. In my next post, I'll break down the 3-Tier Architecture of LocalHands: the frontend (presentation), the NestJS API layer (application logic), and the PostgreSQL/Prisma data layer (persistence) and how this data model sits inside a scalable, resilient system designed for fragmented infrastructure.&lt;/p&gt;

</description>
      <category>prisma</category>
      <category>postgressql</category>
      <category>softwareengineering</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
