Skip to content
AM
Back to Blog

I Built It First. Here's How I Design It Now.

Abdul MoizAuthor
4 min read

I built a construction ERP code-first and ran into slow dashboards, cache misses and lost updates. This is the design-first process I use now: requirements, trade-offs, decision records, and measuring before fixing.


BuildLedger is a construction ERP I built: projects, materials, labour, vendors, expenses, client payments, invoices, salary slips and fund transfers between projects. It's a React SPA talking to an Express API, with PostgreSQL behind Prisma.

I built it the way a lot of us build things: feature first, design later. It worked, and then the problems started showing up. Not crashes, but the slow, quiet kind that make you lose trust in your own code.

This post is about what went wrong, and the design-first process I use now because of it.

What broke

None of these were exotic. Each one was a decision I never consciously made.

The dashboard did its maths in JavaScript. The project financials endpoint loaded every payment, material, labour and expense row for every project, then summed them in Node. It ran six queries one after another, and each one waited for a full round trip to the database before the next could start.

The same data was cached five different ways. On the frontend, the project list lived under list({ all: true }) on some pages, list({ isArchived: false }) on others, list() on the reports page, and a separate active key elsewhere. Same projects, different cache keys, so moving between pages kept refetching data the app already had.

A cache header that did nothing. The financials controller called res.removeHeader("ETag") to disable caching. In Express, the ETag is generated later, inside res.send, so that line never had any effect. And even when a request did come back as 304 Not Modified, the server had still run every query to produce it. (That one gets its own post.)

Money was stored exactly but calculated loosely. The columns were NUMERIC(14,2), but the invoice maths ran on JavaScript numbers. And two people recording a payment against the same invoice at the same time could overwrite each other. (Lost updates and money maths each get a post too.)

Every one of these came from the same habit: I wrote code before I'd decided what "correct" and "fast" meant for this system.

What changes when you design first

A solution architect's job isn't to draw boxes. It's to decide what the system must do and why, before anyone argues about how. So before touching code now, I write down four things.

1. Requirements, including the boring ones

Functional requirements are the features. The ones that bit me were the non-functional ones I'd never written down:

  • Correctness: money must add up to the paisa, and two users must never silently overwrite each other's work.
  • Access: a supervisor sees only their projects, and losing access takes effect within a minute, not whenever their token expires.
  • Traceability: every change to a financial record can be traced to a person and a time.
  • Performance: what "fast" means for the dashboard, and how I'll measure it.

Written down, each one turns into a design constraint. "Never silently overwrite" means transactions and row locks. "Traceable" means an audit log that no module can forget to write to.

2. A picture of the system

A C4-style context diagram fits on one screen and settles a surprising number of arguments:

text
React SPA (Vercel)
  │  React Query cache · JWT access + rotating refresh tokens
  ▼
Express API (/api/v1)
  │  helmet → request timing → logging → CORS → rate limits
  │  → auth → permissions → validation → module
  ├──► PostgreSQL (Prisma, NUMERIC money columns)
  └──► Cloudflare R2 (documents via presigned URLs)

3. Boundaries before services

BuildLedger has 17 domain modules. The tempting move is to make them microservices. The honest answer is that one team, one database and one deploy don't need a network between modules. So it stays a modular monolith: every module has the same layers, and only the repository touches the database.

text
routes → controller → service → repository → Prisma → PostgreSQL

The boundaries are real (no module reaches into another's tables), but they're enforced by structure, not by HTTP.

4. Decisions, written down

For any choice I might regret, I write a short Architecture Decision Record: the context, the options, what I chose, and what it costs. The biggest one in BuildLedger is stored balance vs calculated balance. A stored balance is fast to read but can drift from the ledger; a calculated one is always right but has to be computed. Writing the trade-off down forces me to pick deliberately instead of by accident.

Measure, then fix

The last change is the one I'd tell my earlier self first: don't optimise from a hunch. BuildLedger now sends a Server-Timing header on every response, with time spent in the database, the number of queries, and the total:

text
Server-Timing: db;dur=…;desc="9 queries", total;dur=…

It shows up in the browser's Network tab, so I can see in seconds whether a slow request is slow because of the database, the network, or my code.

What I'd do differently from day one

  • Write the non-functional requirements before the first endpoint.
  • Do maths where the data lives: SUM and GROUP BY in SQL, not loops in Node.
  • Decide on cache keys as a design artefact, not per page.
  • Put the instruments in (timing, query counts, logs with request IDs) before there's a problem to find.

Building first taught me what goes wrong. Designing first is how I stop it going wrong again.

  • #architecture
  • #system-design
  • #nodejs
  • #postgresql
  • #react
Abdul Moiz

Written by

Abdul Moiz

Full-Stack Developer in Islamabad, Pakistan, building with React · Node.js · .NET.