Subtitle: How a split frontend/backend, a NestJS API layer, and a strict relational core hold up under fragmented infrastructure.
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.
1. Why 3-Tiers, Two Repositories
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.

Figure 1: Client, server, and external service layers, and how they connect.
The diagram states the same split as a request path:
browser→React over HTTPS→NestJS over REST→Prisma→PostgreSQL, 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.This split is a direct response to a specific set of non-functional requirements defined early in the project:
| NFR | Target |
|---|---|
| Availability | 99.9% uptime, minimal downtime |
| Performance | Fast page loads, responsive interaction even under concurrent load |
| Security | HTTPS/TLS on all communication; passwords and financial details hashed/encrypted at rest and in transit; protection against SQL injection and XSS |
| Scalability | Handle growing users, services, and transactions without performance degradation |
Table 1: Non-functional requirements driving the architecture.
Engineering Decision: Splitting frontend and backend into separate repos means either layer can be redeployed, scaled, or swapped without touching the other.
Given fragmented connectivity and variable device quality across the target market, this isolation matters more than it would in a single-region SaaS product.
2. Tier 1-Frontend: React 19 + Vite
"dependencies": {
"react": "^19.1.0", // UI library
"react-dom": "^19.1.0", // DOM renderer for React
"antd": "^5.26.1", // component library (tables, forms, modals)
"axios": "^1.10.0", // HTTP client for the backend API
"react-router-dom": "^7.6.2" // client-side routing
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0", // utility CSS, wired into the Vite build
"vite": "^6.2.0" // dev server + production bundler
}
A Single-Page Application: 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.
The app is routed by role, not just by path:
| Category | Prefix | Auth Required | Layout |
|---|---|---|---|
| Public | / |
No |
MainLayout [Header + Footer] |
| Client | /client/* |
Yes (CLIENT) |
ClientLayout [Sidebar] |
| Provider | /provider/* |
Yes (PROVIDER) |
ProviderLayout [Sidebar] |
| Admin | /admin/* |
Yes (ADMIN) |
AdminLayout [Sidebar] |
Table 2: Route prefixes, access rules, and layout per role
Auth state isn't passed manually on every request these two interceptors handle it once, globally:
// Axios interceptors (utils/api.ts)
request: attach Authorization: Bearer <token> from localStorage // every outgoing call is authenticated automatically
response: on 401 → clear token, show toast, redirect to /login // session expiry handled in one place, not per-component
Engineering Decision:
HashRouteroverBrowserRouterwas a deliberate trade it means client-side routing works correctly on static hosting (Vercel) without needing server-side rewrite rules.
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.
3. Tier 2-API Layer: NestJS
- 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.
- Swagger UI documents the API live at
/api/docs. 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.

Figure 2: Component hierarchy detailing child module communication with the root App
The backend runs 23 feature modules off a single root AppModule:
Grouped by domain, not left flat:
| Domain | Modules |
|---|---|
| Infrastructure |
Config, Prisma, Common, Healthcheck
|
| Identity |
Auth, User, Provider, Client, Profile
|
| Marketplace |
Service, ServicePackage, ServiceAsset, ServiceOrder, Category
|
| Engagement |
Booking, Availability, Proposal, Review
|
| Money |
Contract, Payment
|
| Comms |
Messages, Notifications
|
| Platform | Settings |
Table 3: The 23 feature modules, grouped by domain.
Every request follows the same pipeline before it reaches business logic:
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
// main.ts-ValidationPipe config
{
whitelist: true, // strip unknown fields
transform: true, // cast payloads to DTO types
forbidNonWhitelisted: true // reject unexpected fields outright
}
Two flows worth detailing since they cross more than one module:
| Flow | Steps |
|---|---|
| Provider verification |
verificationStatus starts PENDING → Provider uploads ID doc via Profile module → Admin reviews manually → status set to VERIFIED or REJECTED. Only VERIFIED providers can submit Proposals-enforced server-side. |
| Authentication |
POST /api/auth/login → AuthService.validateUser() → lookup by email/phone → bcrypt.compare() against stored hash → JWT signed (email, phoneNumber, sub, name, role, 1h expiry) → returned to client. |
Table 4: Two flows that cross multiple modules.
What gets signed and carried on every request after:
// JWT payload structure
{
"email": "user@example.com", // one of two lookup fields on login
"phoneNumber": "+237...", // the other lookup field phone-first identity
"sub": "<user id>", // standard JWT subject claim
"name": "...", // included so the UI can render it without a refetch
"role": "CLIENT | PROVIDER | ADMIN", // drives both UI routing and RoleGuard checks
"exp": "<issued + 1h>" // short-lived on purpose
}
Engineering Decision: 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.
4. Tier 3-Data Layer: PostgreSQL + Prisma

Figure 4: How a query travels from the service layer down to the database.
- Each box in the diagram is a real step a query takes, not just a simplified picture. A Service first calls
PrismaService.PrismaServicedoesn't talk toPostgreSQLdirectly, it goes through@prisma/adapter-pg, an adapter that lets Prisma run its queries through the standard node-postgres library. - From there, the query goes into a connection pool: a small set of database connections that stay open and get reused, instead of opening a brand new connection for every single request.
- 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.
- The full database design, the User/Profile split, the
ServiceOrder→Proposal→Contract lifecycle,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:
| Model | Role |
|---|---|
Availability |
Provider working hours per dayOfWeek; checked before a direct Booking |
ServicePackage |
Bundled Service offerings tied to a Provider |
ServiceAsset |
Media attached to a Service listing, typed via AssetType- the portfolio/visual-proof mechanism the field research called for |
Message |
senderId / receiverId pairs for in-app Client–Provider comms |
Notification |
Typed, per-user records (message, type, read) tied to events |
SystemSettings |
Platform config: maintenance mode, registration toggles, review auto-approval, currency, support email |
Table 5: Supporting models not covered in the schema deep-dive post.
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
}
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.
Engineering Decision: 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
PaymentMethodvalue is a migration, not a redesign, which is what made that expansion low-risk.
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.
5. Request Flow
Login, end to end, is the clearest illustration of the full stack working together:
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
The same pattern repeats for every other action on the platform-a Client posting a Service Order, a Provider submitting a Proposal, a Contract being accepted:
Frontend request → Controller validation → Service logic →
Prisma → PostgreSQL → Response → UI update
// same six-hop cycle regardless of which module handles the request
No full page reload, no server-rendered round trip-just a tight, predictable cycle through the same three layers every time.
6. Why This, Not Something Fancier
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:
| Commitment | Mechanism |
|---|---|
| Encrypted transport | HTTPS/TLS on every connection |
| Password safety | bcrypt hashing; never returned in API responses |
| Injection protection | Prisma parameterizes every query by construction |
| Failure visibility | Global exception filter + structured, sanitized logging (passwords/tokens redacted before write) |
| Horizontal scaling | Stateless backend modules can scale out, or split into services later, without a redesign |
Table 6: Security and scalability commitments behind the architecture.
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.
Next post:
Access control: how LocalHands distinguishes Clients, Providers and Admins, and how every request crossing this architecture gets authenticated and authorized.

Top comments (0)