ConnectIQ — Architecture
Architecture information is spread across this documentation and the application repository, rather than kept in one architecture.md. This page owns the structural rules; the application files put them where the work happens.
CLAUDE.md— the core rules and a guide to what to read next.src/domains/CLAUDE.md— domain boundaries, events and data naming.supabase/migrations/CLAUDE.md— database changes, permissions and row-level security..claude/rules/design-tokens.md— visual rules for UI work.ARCHITECTURE_RULES.md— security, quality and other rules that do not belong to one path.
Why split it? In Claude Code, relevant rules load as work reaches the files they govern. This keeps each session focused, makes rules easier to maintain beside the code, and avoids another all-in-one copy drifting out of date. Lint, checks and hooks enforce the rules that can be automated. See the full file and enforcement map.
Core principles
One Postgres database, with tables grouped by domain prefix. Each domain owns its writes and exposes data through defined interfaces, making domains easier to build independently and separate later.
The shared database, drawn as if it were already split
1.1 · Why prefixes, not schemas
Cross-domain interaction rules
Use service clients for operational reads, reporting views for shared reports, and events for cross-domain changes.
| # | Interaction | Allowed | Notes |
|---|---|---|---|
| 1 | Store another domain's ID as a reference | Yes | Anywhere |
| 2 | Call another domain's service client / API | Yes | The only operational path for cross-domain data |
| 3 | Snapshot a value at event time | Yes | Subject to §5 |
| 4 | Cross-domain read join | Restricted | Only inside a rpt_* view, owned by Reporting |
| 5 | Application reads a rpt_* view | Yes | The only cross-domain read surface for app code |
| 6 | Insert into shared audit / log tables | Yes | Insert-only |
| 7 | Emit a domain event | Yes | The only way to trigger a cross-domain write |
| 8 | Same-domain direct read | Yes | Unrestricted |
| 9 | App reads another domain's raw table | No | Including reporting and dashboard code |
| 10 | Nested/embedded PostgREST select across prefixes | No | See §2.2 |
| 11 | Direct write to another domain's table | No | No exception |
| 12 | Cross-domain database transaction | No | No exception |
| 13 | Admin repair script | Exception | Approved, logged, never part of a normal workflow |
2.1 · The operational read rule
2.2 · Cross-prefix foreign keys — kept
| Keep FK | No FK | |
|---|---|---|
| Integrity | Orphans impossible | Found late |
| Reverse cost | One statement | Unknown cleanup |
| Backfill | Bad rows rejected | Land quietly |
2.3 · Keep each business transaction inside one domain
How domains trigger changes in other domains
A domain records what happened in plat_domain_events, in the same transaction as its change. A dispatcher delivers the event; the receiving domain updates its own data. This is the outbox pattern: rolled-back changes produce no event, and committed changes keep their event for delivery. Consumers must be idempotent: processing the same event twice has the same result as processing it once.
3.1 · Delivery
3.2 · Event names
3.3 · Internal events and external webhooks
How to read another domain’s data
A read projection exposes another domain’s data without giving the reader ownership. Choose by purpose:
rpt_* view
Domain service client
int_*_mirror
When to copy a value or store its ID
If the source changes tomorrow, should this record show the new value?
No — save a snapshot
Yes — store a reference ID
Record IDs, business codes and references
Every table uses id uuid primary key default gen_random_uuid(). UUIDs keep independently created records from sharing a sequence across preview, import and production data.
| Purpose | Convention |
|---|---|
| Record ID | UUID primary key; use it to address records in APIs. |
| Human-facing code | Separate column such as customer_code or job_no, generated by a database function. For display and lookup, never the primary key. |
| Reference in the same domain | <entity>_id, e.g. job_id. |
| Reference to another domain | <prefix><entity>_id, e.g. cl_location_id. |
| Status values | Use shared constants or enums, never raw status strings. |
| External system ID | Airtable, HubSpot, Freshdesk and Xero IDs live only in int_external_system_mappings (system, entity_type, internal_id, external_id), never in domain tables. |
Ownership tiebreak rule
Reporting layer
Reporting reads domain tables directly — but only through rpt_* views. The view is the seam that makes deferring a real read-model layer safe.
security_invoker = true, a portal user could see every customer's rows through a view that looks correct.Migration: backfill and cutover
Not a continuous two-way sync — a transform-and-load into real domain tables, followed by a permanent cutover.
Cutover entities — customers, locations, contacts, brands
Mirror entities
9.2 · Critical requirements, by consequence
Domains, prefixes and table ownership
The eleven prefixes are locked. Never invent a variant for an existing domain. The responsibilities and table groups below are indicative, not a current schema inventory; use the delivery decisions for settled ownership and the ownership rule for open questions.
| Prefix / domain | Indicative table groups | Boundary or open question |
|---|---|---|
cl_Customer & Location | Customers, brands, locations, geography and arrangements. | Contacts: Customer or Commerce. |
com_CRM / Commerce | Opportunities, quotes and lines, products, price books, billing entities and payment terms. | Commercial contracts may differ from subscription contracts. |
plan_Sales Handover | Signed quote to execution scope: BOM, resources and readiness. | Job creation batches and templates: Planning or Ops. |
ops_Field Operations | Jobs, tasks, schedules, maintenance, partners and workers. | Read customer, location, inventory and subscription data through their owners. |
inv_Inventory / Asset | Resources, units, stock locations, movements, sets, scans, delivery notes, attributes and specs. | Serial-bound licences belong here; movable entitlements belong to Subscriptions. |
sub_Subscription | Contracts, subscriptions, types, renewals and unit assignments. | Keep the commercial lifecycle separate from the device; see the subscription decision. |
sup_Support / Incident | Tickets, logs, SLA, out-of-service items, menu items and compensation. | Agents and Weeks: ownership open. |
mon_Device Health | Heartbeats, TargetR/Lisa status, uptime and alerts. | Inventory identifies the device; Monitoring tracks its health. |
int_Integration / Sync | Import staging, external ID mappings, webhooks, sync logs and migration mirrors. | Moves data; owns no business decisions. |
rpt_Reporting | Cross-domain views, read models and dashboards. | Read-only; views use security_invoker = true. |
plat_Platform | Internal events, audit, configuration and identity infrastructure. | Broader Identity & Access, Finance & Billing, Procurement, Notifications and Audit ownership remains to be assigned when needed. |
Decision record — Subscriptions separate from Inventory
The device swap, drawn
Separate domain
- Commercial, time-based lifecycle: start, renew, suspend, cancel
- Device swap is a non-event
- 1 subscription : many units, many locations
- Supports software-only products
- Ops actions cannot affect billing
- Failure mode: drift — caught by a weekly reconciliation report
Folded into Inventory
- One lifecycle, wrong for half the cases
- Swap breaks continuity and revenue reporting
- Forces a false 1:1 cardinality
- Software-only needs a fake inventory record
- Warehouse edits touch billing
- Failure mode: structural, unrecoverable without remodelling
11.1 · Linking subscriptions to units
sub_subscription_units
subscription_id uuid
inv_unit_id uuid
valid_from date
valid_to date null
Appendix A — Rejected: schema-based separation
const supabase = createClient(URL, KEY,
{ db: { schema: 'quotation' } })
// or per query
await supabase.schema('quotation')
.from('quotes').select('*')
Why rejected
ALTER TABLE ... SET SCHEMA plus a rename is the natural first step of a real microservice split.Appendix B — Lovable knowledge-file rules
The former paste-in rules are preserved in the original Lovable knowledge block. For current work, use the application files listed at the top.