Case study · Data architecture

One source of truth

Property data lived in a property-management system, a large Airtable base and spreadsheets, and none of them agreed. I designed a relational core database the whole company could build on, loaded it with every property, unit, owner and vendor, and made sure each app could only see what it needed.

RoleArchitect and builder
WhenAug – Oct 2026
ToolsSupabase (Postgres), Buildium API, Airtable, Claude Code
ScopeCompany-wide data layer
36
tables, 29 migrations
4
core record types: properties, units, owners, vendors
1
durable ID per building, owned by us
0
changes when the sync re-runs

The problem

Every tool we added kept its own copy of the same records. A building could have a different ID in three systems, owners were linked to properties by hand, and nobody could say which copy was right.

New tools were being blocked by the same question every time: where does this data come from, and can we trust it?

Constraints

No budget for a vendor build

An outside studio bid on the work. I wrote the requirements they bid against, then brought the build in house.

The old systems stay in use

The property-management system remained the place staff worked, so the new layer had to sync with it, not replace it.

Records change owners

When a property changes ownership, the old system creates a brand-new record, so its ID can't be trusted as a permanent key.

Least access

Apps and contractors should never hold a key that can read everything.

Key decisions

1

Where a record is born decides which way it flows.

Records created in the property-management system sync one way into the core. Records born in our own tools flow back out. Two-way sync is only used where both sides have a modified timestamp and a change notice.

This one rule settled most "which copy is right?" arguments before they started.
2

Give every building a stable key of our own.

Because ownership changes create new records upstream, the core assigns its own durable ID and keeps the upstream IDs as references.

3

Deny by default, and one scoped role per app.

Security starts closed. Each app gets its own database role that can read and write only its tables.

When a new tool needed property data, I gave it a role that could read exactly that, and nothing else.
4

Re-runs must change nothing.

Every load is idempotent: running it twice produces zero changes. That made it safe to re-sync any time without fear of duplicates.

How it works

Property systemsource for properties, units, owners
→
Nightly syncidempotent upsert
→
Core database36 tables, deny-by-default
→
Scoped rolesone per app
→
Apps and reportsreceipts, console, dashboards
Data flows one way from where it is born. Each app reads the core through its own limited role.

Outcome

What I'd do differently