RealtyOS: Multi-Tenant Property Management SaaS
In development: a multi-tenant SaaS for apartment builders and residents. Three Angular 22 apps on a typed API contract and an Express 5 API on PostgreSQL 16, where row-level security isolates each company.
- Angular 22
- Zoneless Angular
- Angular Signals
- NgRx
- TypeScript
- Tailwind CSS
- Apache ECharts
- Node.js
- Express 5
- PostgreSQL 16
- Row-Level Security
- Drizzle ORM
- Zod
- OpenAPI
- AdminJS
- PgBouncer
- Vitest
- Testcontainers
- Docker
- nginx

Architecture

Overview
Status: in development, not yet deployed. RealtyOS is a multi-tenant SaaS for apartment builders and their residents. It replaces paper registers, phone calls and WhatsApp groups for dues, complaints and notices. It is not a property listing site.
I counted every figure below from the code in three repositories: the Express API, the Angular apps and a small marketing-site scaffold. I did not run the test suite or deploy anything for this write-up.
Unlike my MongoDB projects, it runs on PostgreSQL 16 with row-level security. It has four surfaces:
- Staff app (Angular): for builder and property-management staff. It covers buildings, floors and units, residents and occupancies, bills and payments, complaints, notices, tasks, management contracts, ownership registers, handovers and transfers, and staff and roles.
- Resident portal (Angular, mobile-first): for tenants and apartment owners. They see bills and payments, raise complaints and read notices.
- Platform app (Angular): for the platform company. It approves new companies, buildings and amenities and manages organisations, policies and platform users.
- Owner panel (AdminJS): an admin view of the database tables for the platform owner. It runs inside the API process, and its delete action is a soft delete that can be restored.
A fifth piece, a public marketing site, is planned. Its repository (real-estate-crm) is a small Angular 21 scaffold with a server-rendered landing page and a lead form that is not connected to the API yet. The API already has two public endpoints for it (a reCAPTCHA-checked session), but they stay switched off until that site starts.
The challenge
- Many builders share one database. One missing
org_idfilter in any operation of a large API would leak another company's residents, bills or complaints. - Residents need a narrower view than staff.
- Soft-deleted rows must disappear from every query.
- Emails for invitations, approvals, bills and handovers must be sent reliably, without holding database transactions open.
- Three front ends call one API, so the contract between them must not drift.
What I built
- Backend: an Express 5 API with 261 operations: 187 for staff, 25 for residents and 45 for the platform app, plus 2 public endpoints and 2 health probes. It covers the tenant model, approval workflows (a company that is not yet approved gets
403 ORG_NOT_APPROVED), billing, complaints, notices, tasks, ownership registers, management contracts and handovers. A completed handover produces a records package (a PDF certificate plus a ZIP). - Database: 28 ordered SQL migrations (about 7,250 lines) that create 73 tables, 95 row-level security policies and 77
SECURITY DEFINERfunctions, with four database roles. - Access control: 81 permission keys and 7 system roles. Three token audiences (staff, resident and platform) keep each API from accepting another one's tokens.
- Security: passwords are hashed with argon2id and checked against a common-password list. Refresh tokens rotate, and reusing an old one revokes the whole token family. TOTP two-factor sign-in has recovery codes, a company can require it, and platform users must use it. TOTP secrets and resident ID numbers are encrypted with AES-256-GCM. Audit rows are written in the same transaction as the change.
- Money and areas: money is stored in minor units (bigint) with the organisation's currency, and areas in square metres.
- Front ends: the staff app, the resident portal and the platform app all exist. The staff app and the portal still have final steps open.
- Still open: the project checklist leaves the staff app's final checks, the resident portal's 3D building view (a coming-soon page today), tests and hardening, production images and go-live unticked, and the public site is only a scaffold.
Tech & architecture
- Frontend: Angular 22 without zone.js, signals for page state and NgRx only for app-wide state. It also uses Tailwind CSS 3.4, Apache ECharts and TypeScript 6. ESLint boundary rules keep the apps from importing each other.
- Backend: Node.js 24, Express 5 and TypeScript 6. It uses PostgreSQL 16 with row-level security, Drizzle ORM, PgBouncer, and Zod 4 with OpenAPI generated from it. Tokens use jose (JWT) and otplib (TOTP). Files go to Google Cloud Storage, S3 or R2 with signed URLs (local disk in development). Email goes out through Nodemailer over SMTP, with 38 templates. The owner panel is AdminJS.
- Testing: Vitest and supertest, plus Testcontainers running a real PostgreSQL 16. The repository has 71 test files (51 integration and 20 unit) with about 710 test cases.
- Infrastructure: one non-root Docker image that runs as the API, the worker or the migration job. The production Compose file adds PgBouncer in transaction mode and nginx, which serves the built front ends and proxies
/api. The API, worker and nginx containers are read-only, run with all capabilities dropped and cannot gain new privileges. - Size: about 42,600 lines of TypeScript in 204 source files.
Architecture
- Three Angular 22 apps share one library. ESLint enforces the library boundaries: apps never import each other, and UI code never imports data-access code.
- Every HTTP call goes through typed clients generated from the API's OpenAPI documents with
ng-openapi-gen, and the clients are never edited by hand. Those documents are generated from the same Zod schemas that validate the Express 5 routes, and a unit test compares the mounted routes with the documents, so the contract cannot drift without anyone noticing. - Each request passes through a request id, Helmet, CORS, rate limiting and a 1 MB JSON body limit. Then authentication, the approval and MFA-policy checks and a permission check run.
- Each staff request runs in one PostgreSQL transaction. The organisation and user are set as transaction-local settings, and row-level security policies isolate each organisation. Residents connect through a separate pool and database role with their own policies.
- Soft delete is enforced inside the policies (
deleted_at IS NULL), and deletes on the 26 soft-deletable tables go through aSECURITY DEFINERfunction instead of a hardDELETE. - The audit row is written in the same transaction as the change. Work that touches the outside world, such as deleting a storage object, runs only after the commit.
- A worker process polls every 10 seconds by default. It sends the email outbox and runs due handovers on each tick, marks overdue bills and activates due ownership changes every hour, and builds the handover records packages in a loop of their own.
- The AdminJS owner panel and the platform API are mounted inside the API process. They use their own small connection pool on a separate database role that bypasses row-level security.
Hard problems
Tenant isolation the application cannot forget
Isolation is enforced by PostgreSQL row-level security, not by remembering a WHERE clause. Here is how it fits together:
- The API connects as a role that is not a superuser and cannot bypass row-level security. Timeouts live on the role (15 seconds per statement, 30 seconds idle in a transaction), so they also work through PgBouncer.
- Every request opens a transaction and sets the organisation and user for that transaction only.
- Every policy compares each row against those settings and hides soft-deleted rows.
- Residents use a separate role whose policies also check the resident id, which comes only from the verified token.
The integration tests run against a real PostgreSQL. They include reads across organisations returning nothing, inserts with another organisation's id failing, no context returning no organisation data, the context not leaking to the next user of a pooled connection, and a forged resident id returning zero rows.
-- One policy shape per org table (simplified from migrations 0001, 0002 and 0015)
CREATE FUNCTION app_org_id() RETURNS uuid LANGUAGE sql STABLE
AS $$ SELECT NULLIF(current_setting('app.org_id', true), '')::uuid $$;
ALTER TABLE units ENABLE ROW LEVEL SECURITY;
ALTER TABLE units FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON units TO realtyos_app
USING (org_id = app_org_id() AND deleted_at IS NULL)
WITH CHECK (org_id = app_org_id() AND deleted_at IS NULL);
// One request = one transaction with org context (simplified from src/db/with-org-tx.ts)
export const withOrgTx = <T>(db: Db, ctx: OrgCtx, fn: (tx: Tx) => Promise<T>) =>
db.transaction(async (tx) => {
await tx.execute(sql`select set_config('app.org_id', ${ctx.orgId}, true),
set_config('app.user_id', ${ctx.userId}, true)`);
return fn(tx);
});
Reliable email without holding database locks
Emails must not be sent twice, and they must survive a restart. But a slow mail server must never keep a transaction open. That would break PgBouncer's transaction pooling and trip the app role's idle-in-transaction timeout. So the worker sends emails through a transactional outbox:
- A business change and its
email_outboxrow are written in the same transaction. - The worker leases a batch of up to 20 rows in one short statement, using
FOR UPDATE SKIP LOCKEDso two workers never claim the same row. - The email is sent with no transaction open.
- A template error fails the email permanently. A transport error is retried after 1, 5 and 15 minutes, and the email fails after 4 attempts.
-- Lease a batch in one short statement, then send outside any transaction (simplified)
UPDATE email_outbox
SET attempts = attempts + 1,
send_after = now() + interval '5 minutes' -- lease runs out if the worker dies
WHERE id IN (
SELECT id FROM email_outbox
WHERE status = 'queued' AND send_after <= now()
ORDER BY send_after
LIMIT 20
FOR UPDATE SKIP LOCKED -- workers never claim the same row
)
RETURNING id, to_email, template_key, payload, attempts;
Results in numbers
- 4 Angular projects (3 apps and a shared library) with 185 components, 121 of them using OnPush.
- 109 lazy-loaded routes.
- 257 generated typed client functions: 187 for the staff app, 25 for the resident portal and 45 for the platform app.
- 24 frontend spec files.
- 113
aria-labelattributes across 59 files. - 257 documented staff, resident and platform operations across 204 paths, plus 2 public endpoints and 2 health probes.
- 28 SQL migrations creating 73 tables, with 95 row-level security policies and RLS switched on for 54 of those tables.
- 4 database roles (migrator, app, resident and owner panel), 81 permission keys, 7 system roles and 38 email templates.
- 71 backend test files (51 integration tests against PostgreSQL in Testcontainers and 20 unit tests) with about 710 test cases.
Outcome
In development, and not yet deployed. The backend, the database design and all three Angular apps exist in code. The resident portal's 3D building view, the final checks, hardening and go-live are still to come. There are no users or production numbers yet. What exists is the foundation: a typed contract shared by every front end, a database that enforces isolation, and integration tests designed to run against a real PostgreSQL.
