Skip to content
Jomari Abejo
Jomari Abejo

Full Stack Developer

How MotorPH Is Built: React, Spring Boot, PostgreSQL — and Knowing When Not to Scale

MotorPH Enterprise is an all-in-one HR, payroll, recruitment, CRM, and inventory platform built for Philippine businesses. It runs payroll from approved attendance, computes SSS, PhilHealth, Pag-IBIG, and BIR withholding, and generates the government forms — self-hosted, multi-role, in production.

This post is the honest version of how it works: how the frontend and backend behave under the hood, why I chose PostgreSQL, why it runs on a single VPS today, what would break during a sales spike, why I deliberately did not reach for microservices, and how the whole thing is secured. No architecture-astronautics — just the decisions I actually made and why.

The shape of the system

MotorPH is a modular monolith: one React single-page app, one Spring Boot service, one PostgreSQL database, wrapped in Docker Compose and served through Nginx on a Linux VPS.

Browser
    HTTPS
  
Nginx  ──►  React SPA (static build, Vite)
  
    /api/*  (reverse proxy)

Spring Boot (Java 21)  ──►  PostgreSQL 16
  │                             ▲
  └── JWT auth, RBAC,           │
      JPA/Hibernate ────────────┘
      (all one Docker Compose stack, one VPS)

The whole thing is one docker compose up away from running, and I deploy an isolated stack per client — their data stays on their box. Keep that picture in mind; every decision below flows from it.

How React works behind the scenes

MotorPH's frontend is React 19 with TypeScript, built by Vite, styled with Chakra UI, with TanStack Query for server state and AG Grid for the big data tables.

The mental model most people have — "React updates the DOM" — hides the interesting part. React never lets you touch the DOM directly. Instead:

  1. You describe UI as a function of state. A component returns a tree of elements (plain JS objects, not DOM nodes) describing what the screen should look like for the current state.
  2. State changes trigger a re-render. When you call a state setter (useState) or a query resolves, React re-runs the component function to produce a new element tree.
  3. Reconciliation diffs the trees. React compares the new tree with the previous one and computes the minimal set of real DOM mutations needed. This is the "virtual DOM" — it exists so React can batch and minimize expensive DOM writes.
  4. Fiber schedules the work. React's Fiber reconciler splits rendering into small units it can pause, prioritize, and resume, so a big update doesn't freeze the tab. There are two phases: render (compute the diff — interruptible, no side effects) and commit (apply DOM changes — synchronous).

Two practical consequences shape how MotorPH's frontend is written:

  • Data flows one way. Parent to child, via props. State lives as close to where it's used as possible. This is why a bug is usually easy to localize — you follow the data down.
  • Hooks are just ordered calls. useState, useEffect, useMemo rely on being called in the same order every render, which is why they can't live inside conditionals. useEffect runs after commit, which is where I put things that talk to the outside world.

Where MotorPH leans on this deliberately:

  • Server state is not component state. TanStack Query owns anything that comes from the API — caching, deduping, background refetching, optimistic updates. Components stay dumb; the cache is the source of truth. This alone removes a huge class of "stale data" bugs.
  • The heavy tables don't live in React state. AG Grid runs in server-side row model: it asks the backend for one page of rows at a time with sorting/filtering pushed down to SQL. The browser never holds 50,000 payroll rows in memory, so scrolling stays smooth.
  • Forms validate with Zod. react-hook-form plus Zod schemas give typed, declarative validation, and the same shape of rules is enforced again on the server (never trust the client).
  • Code splitting keeps first load small. Routes are lazy-loaded, so opening the payroll module doesn't download the inventory module.

How Spring Boot works behind the scenes

The backend is Spring Boot 4 on Java 21, using Spring Web MVC, Spring Data JPA (Hibernate), Spring Security, and java-jwt for tokens.

"Spring Boot" is really three ideas stacked together:

  1. An embedded server. There's no external Tomcat to install and configure — Boot ships an embedded servlet container inside the JAR. java -jar app.jar is the web server. That's what makes it trivial to Dockerize.
  2. Auto-configuration. Boot looks at what's on the classpath and wires sensible defaults. See a JDBC driver and a datasource URL? It configures a HikariCP connection pool. See Spring Web? It sets up the DispatcherServlet. You override only what you need.
  3. The IoC container. This is the heart of Spring. At startup, Spring scans for your beans (@Service, @Repository, @Component), instantiates them, and injects their dependencies (constructor injection). Your code never does new PayrollService(...); it asks for one and Spring hands over a fully-wired, singleton instance. That inversion is what makes the code testable — in a unit test I inject a mock repository instead of a real one.

A single request through MotorPH looks like this:

HTTP request
   Servlet filter chain (Spring Security: authenticate JWT, load roles)
   DispatcherServlet (front controller: match URL to a handler)
   @RestController  (parse/validate the request DTO)
   @Service         (business rules; @Transactional boundary)
   @Repository      (Spring Data JPA  Hibernate  SQL)
   PostgreSQL
   MapStruct maps entities  response DTO  JSON

The layers aren't ceremony — they're where different concerns live:

  • Controllers only translate HTTP to method calls and back. They validate input (Jakarta Bean Validation) and never contain business logic.
  • Services hold the rules — how overtime is computed, how a payroll run is finalized — and define the transaction boundary. @Transactional means either the whole payroll run commits or none of it does; there is no half-finished run.
  • Repositories are interfaces. Spring Data JPA generates the implementation, and Hibernate turns method calls into parameterized SQL. I write findByEmployeeIdAndPeriod(...), not string-concatenated queries.
  • DTOs at the edges. MapStruct maps between database entities and the JSON the API exposes, so internal schema changes don't leak out to the frontend.

Because it's a modular monolith, all of this is one deployable unit with clean internal package boundaries — not a network of services. That distinction is the whole point of the deployment section below.

Why PostgreSQL

Payroll is money, and money is the one domain where "eventually consistent" is a bug, not a feature. PostgreSQL 16 is the store precisely because it's boring and correct:

  • ACID transactions. A payroll run touches many rows across many tables. Wrapped in one transaction, it either all lands or all rolls back. Postgres guarantees this.
  • Constraints enforce truth at the lowest level. Foreign keys, NOT NULL, UNIQUE, and check constraints mean the database itself refuses invalid data, even if a bug slips past the application. For compliance data, that's a safety net you want.
  • Relational data is relational. Employees, departments, payroll runs, contributions, and government forms are deeply interconnected with strong integrity requirements. This is the textbook case for a relational database, not a document store.
  • Flyway migrations. Every schema change is a versioned, ordered SQL migration checked into git (MotorPH is past V88). Every environment — my laptop, a client's VPS — converges to the exact same schema. No manual "ALTER TABLE in production."
  • HikariCP pooling. Opening a DB connection is expensive, so Spring Boot keeps a small pool of them open and hands them out. Tuning that pool size is one of the most important knobs when traffic grows (more on that next).

Postgres comfortably handles the load of the businesses MotorPH serves. The day it doesn't, the answer is indexes, read replicas, and caching — long before it's "a different database."

Deployment: why one VPS is the right call right now

MotorPH deploys as a single Docker Compose stack — Nginx, the Spring Boot JAR, and PostgreSQL — on one Linux VPS, one isolated stack per client. For a business with low, steady traffic, that is not a compromise. It's the correct architecture. Here's the honest reasoning:

  • Cost. One predictable monthly bill. No per-request pricing, no surprise egress charges, no managed-service premiums.
  • Simplicity is a feature. One machine to reason about. One place to look when something breaks. One backup to take (a pg_dump on a cron). The entire operational surface fits in one person's head — which matters when that person is also the developer.
  • Data ownership. Philippine payroll data is sensitive. Self-hosting on the client's own VPS means employee records never leave their infrastructure. For many businesses, that alone wins the deal.
  • Reproducibility. docker compose up -d recreates the whole stack identically anywhere. There's no "works on my machine."
  • No lock-in. It's plain Docker, Nginx, and Postgres. It runs on any VPS provider — or the client's own hardware.

The trap to avoid is treating "one VPS" as embarrassing. Plenty of profitable software runs on a single well-chosen box for years. Scale is a problem you earn; paying its complexity tax before you have the traffic is just burning money and time.

What breaks during a sales spike — and the ladder up

The honest limitation: one VPS is a single point of failure with a vertical ceiling. During a spike — say a promo or a payroll deadline when everyone logs in at once — a few things bite in a predictable order:

  • Resource contention. The app, the database, and Nginx share the same CPU, RAM, and disk. A heavy report can starve request handling.
  • Connection pool exhaustion. If more concurrent requests need the database than HikariCP has connections, requests queue and latency spikes.
  • Database I/O. Postgres on the same disk as everything else becomes the bottleneck under write-heavy load.
  • GC pauses and worker limits. The JVM and Nginx both have finite headroom; past it, response times climb.
  • No failover. If that box goes down, the app is down.

The mistake would be to "solve" this by rewriting into microservices. The actual fix is a ladder you climb only as far as the traffic forces you, measuring at each rung:

  1. Scale vertically first. A bigger VPS (more CPU/RAM) is the cheapest, fastest win and often the only step a growing SME ever needs.
  2. Tune what you have. Right-size the HikariCP pool, add the missing database indexes, tune Postgres (shared_buffers, work_mem), enable Gzip/Brotli and HTTP caching at Nginx.
  3. Cache and offload. Put static assets on a CDN. Add Redis for hot, read-heavy data. Cache expensive report queries.
  4. Split the database's load. Add a read replica so reporting and dashboards stop competing with writes. Consider managed Postgres so backups and failover aren't your problem.
  5. Scale the app horizontally. The Spring Boot app is already stateless (auth is a JWT, not an in-memory session), so I can run several instances behind a load balancer — no session stickiness needed.
  6. Make heavy work async. Move PDF generation and bulk payroll runs onto a queue so a spike in those doesn't block interactive requests.

Notice that microservices aren't on this ladder until much later, if ever. Every rung above buys real capacity without distributing the system.

Why a startup shouldn't jump to microservices

Microservices solve organizational and extreme-scale problems: many teams shipping independently, services that must scale on wildly different curves. A startup or a low-traffic business has neither problem yet, and adopting them early trades problems you have for problems you don't:

  • You inherit distributed systems. Network calls fail. Transactions that were one @Transactional block become sagas and eventual consistency. Debugging spans machines. You now need service discovery, distributed tracing, and orchestration — infrastructure that does nothing for the user.
  • You can't draw the boundaries yet. Good service boundaries come from understanding the domain. Early on you don't, so you'll cut them in the wrong place and pay to move them across a network. A monolith lets you move a boundary with a refactor, not a redeploy of three services.
  • It's the wrong tax for the team size. More services means more pipelines, more configs, more failure modes — multiplied by a small team's time. That's the definition of premature optimization.

The pragmatic path — the one MotorPH follows — is monolith-first with clean internal modules. MotorPH's packages already have clear seams (payroll, HR, recruitment, inventory, CRM). If one module ever genuinely needs independent scaling or a separate team, I can extract it into its own service then, from boundaries I now actually understand.

To be clear, I do know how to build the other thing: my companion event-driven inventory project is three independently-deployable Spring Boot services talking over RabbitMQ with a database per service — built specifically to learn distributed patterns. That's exactly why I don't impose them on MotorPH: I've felt the cost, and MotorPH hasn't earned it.

How we make MotorPH secure

Security in MotorPH is designed in at every layer, not bolted on. Each item below is something the system actually does:

  • HTTPS everywhere. TLS is terminated at Nginx in front of every service; nothing is served over plain HTTP.
  • Stateless JWT auth. Login issues a short-lived, signed JSON Web Token (java-jwt). The server keeps no session, which is also what lets the app scale horizontally later. Tokens carry the user's identity and are verified on every request in the Spring Security filter chain.
  • Passwords are hashed, never stored. BCrypt (salted, adaptive work factor) via Spring Security. A database dump reveals no usable passwords.
  • RBAC with least privilege. Seven role types (System Admin, HR Admin, Payroll Admin, Employee, and more), with role-switching for users who wear multiple hats. Routes and methods are guarded per permission, so a branch employee simply cannot reach payroll admin endpoints.
  • Validate everything, trust nothing from the client. Jakarta Bean Validation on every incoming DTO on the server, mirrored by Zod on the client. The client validation is for UX; the server validation is the real gate.
  • SQL injection is structurally prevented. All data access goes through Spring Data JPA and Hibernate with parameterized queries. There is no string-concatenated SQL to inject into.
  • XSS is prevented by default. React escapes rendered content, and I don't feed untrusted data into dangerouslySetInnerHTML.
  • Least-exposure APIs. A CORS allowlist restricts who can call the API from the browser, and DTOs mean the API never accidentally serializes an internal field.
  • Audit trails. Sensitive actions are logged, so "who finalized this payroll run" has an answer.
  • Security in CI, not just in code. Every push runs CodeQL (static analysis), gitleaks (secret scanning), Dependabot (dependency updates), and 500+ Playwright end-to-end tests across the app.
  • Data residency as a control. Because each client is self-hosted, their data physically stays on their infrastructure — often the strongest privacy guarantee you can give.

The framing I hold myself to is the OWASP Top 10: authentication, access control, injection, and the rest are checklists I design against, not incidents I react to.

The throughline

MotorPH isn't impressive because it uses trendy infrastructure — it uses deliberately boring, correct infrastructure. React and Spring Boot handle the hard parts so I can focus on the domain; PostgreSQL keeps the money honest; one VPS keeps operations sane and data private; and security is a property of every layer rather than a feature at the end.

The most senior decision in the whole system is the one that isn't there: it doesn't use microservices. Knowing what to leave out — and being able to explain exactly when that answer would change — is the part of engineering I care about most, because in the end it's what keeps the software fast, safe, and pleasant for the people who actually use it.