JetCustoms: the whole shop on one system, in three months
- Client
- JetCustoms
- Sector
- Automotive
- Engagement
- Turnkey project
- Duration
- 3 months, first conversation to production
- Team
- One senior engineer
- Stack
- Next.js · TypeScript · Postgres
- On corvale.pt
- The business write-up
The challenge
Ten users, five business domains, and no system of record. Jobs, hours, parts and client statements each lived in a separate spreadsheet or hand-made PDF, so the same entity existed several times over with no key joining the copies and no constraint preventing them from diverging. Every aggregate a manager asked for — cost per vehicle, margin per project, hours booked against a job — was recomputed by hand from documents that could already disagree, and nothing in the process was able to detect that they did.
What we did
One application over one Postgres database, with the five domains modelled as bounded contexts inside a single schema rather than as separate services. Each fact — an hour, a part, a vehicle, a settlement line — is written once and referenced by key; aggregates are derived by the engine rather than assembled by a caller. Authorisation is a schema concern, not a UI concern, so the same predicates hold for the application, an admin path and an ad-hoc query alike. Shipped with a handbook and an engineering guide, so the shop is not dependent on us to change it.
The decision that shaped every other one: a cross-domain question should be a join, not an integration.
One database rather than five services buys referential integrity enforced by the engine, cross-domain reads inside a single transaction, and no inter-service contract to version. What it costs is independent deployability: a migration touching finance ships with everything else, and the module boundaries are a review discipline rather than a wall the runtime enforces. At ten users with one engineer that trade is correct by a wide margin. The boundaries are drawn in the schema from the first migration rather than discovered later, which is what keeps the decision reversible if the shape of the problem changes.
What it covers
| Module | What it owns | Replaced |
|---|---|---|
| ERP | Projects, vehicles, hours, materials, approvals | Excel, WhatsApp |
| Finance | Settlement notes, rates per section, PDF statements | Hand-made PDFs |
| HR | Timesheets, schedules, holidays and time off | Paper, reprinted |
| CRM | Clients, key account owners, automatic follow-ups | Memory |
| CMS | Public content in two languages; drafts, preview, one-click publish | An agency |
How it is built
| Area | Implementation |
|---|---|
| Authorisation | Row-level security policies on every table. Five domain roles, many-to-many with users, so one account can hold more than one. The predicates apply to the application, admin paths and an ad-hoc session equally — there is no caller trusted by its origin |
| Authentication | Password or magic link behind a swappable provider. A new principal authenticates but reads nothing until approved, so the default state of an unknown user is no rows rather than whatever the interface remembered to hide |
| Data integrity | Totals are generated columns and triggers, never application arithmetic. Settlement lines are append-only once issued and a correction is a new line, so the history of a figure survives being corrected. A parity test asserts every total equals the sum of its lines |
| File transfer | Single-use signed URLs issued by the application, redeemed directly against object storage. No file bytes traverse the app server, so upload size is not a deployment concern and an expired link is simply expired |
| Schema changes | Dev first, always. Ordered, recorded migrations, never hand-edited against the live schema. Types are generated from the schema and ship in the same commit, and CI fails the build when code and database disagree about shape |
| Tests | Unit tests, an RLS matrix and the parity test, all run in CI against a real database rather than a mock — the behaviour under test belongs to Postgres. Lint at zero warnings. Destructive maintenance scripts are tested to refuse when the target is production |
| Environments | Two: production, and a disposable clone of its schema and data for development. Copies flow one way only. The clone is noindex so it never competes with the live site in search |
| Build and deploy | One workflow per branch, same ordered steps: checks, migration, type regeneration, SQL tests, build, deploy. The pipeline triggers the deploy as its final act and the host's git auto-deploy is off, because a build that starts on push races the migration it depends on |