When this matters
| Decision | Why it matters |
|---|---|
| What’s your stable identifier for a person? | Survives email changes, VOMO ID changes, system migrations |
| How do you store VOMO IDs? | Determines join cost, lookup performance, deletion handling |
| How do you model participations? | The most-data-intensive part of any reporting integration |
| How do you handle the schema differences (snake_case, type oddities)? | Determines whether your code needs to know about API quirks |
| What’s denormalized vs. computed? | Trade-off between storage and query cost |
Principle 1: stable partner identifiers
Don’t make VOMO IDs your primary keys. Use partner-assigned identifiers as the stable reference, and link them to VOMO IDs via a mapping table.The pattern
partner_person_id is your stable key. The mapping table is the bridge to VOMO. The same pattern extends to Projects, Groups, Forms, etc.
Why not just use VOMO IDs
| Concern | What happens if you use VOMO IDs as primary keys |
|---|---|
| Customer migrates to a different platform | All your foreign keys break |
| You join with CRM+ data | CRM+ has its own IDs; you need a translation layer anyway |
| A VOMO record is deleted | You lose the join target; orphaned references everywhere |
| You add a new data source later | Schema is already locked to VOMO |
| The customer’s identity system changes their email | If you stored by email, joins break |
Identifier generation
A few options forpartner_person_id:
| Format | Pros | Cons |
|---|---|---|
| UUID | Universal, no coordination | Long; opaque |
Customer-prefixed (e.g., cust001-prs-12345) | Self-describing; debug-friendly | Coupled to customer ID format |
Slug-based (e.g., bruce-wayne-xj9k) | Human-readable | Collisions; profanity risk |
| Sequential | Compact; sortable | Reveals scale; bad for multi-tenant |
Principle 2: mapping tables, not foreign keys
When linking partner-side records to VOMO records, use a mapping table rather than embeddingvomo_user_id as a foreign key everywhere.
The pattern
When mapping tables get complex
For multi-API integrations (Volunteer + CRM+ + Raise), the mapping pattern scales:Principle 3: snake_case → camelCase translation at the boundary
VOMO usessnake_case for all fields. Your application code probably uses something else (camelCase in JavaScript, PascalCase in C#, etc.). Translate at the API client boundary, not throughout your code.
The pattern
JavaScript
| Benefit | Detail |
|---|---|
| Application code uses one convention | No mixed snake_case/camelCase across the codebase |
| API quirks are quarantined | Audit-flagged issues (hours as string, verified as 0/1) are handled in one place |
Defensive translation for audit-flagged quirks
The Volunteer API has documented quirks (see audits) that production code should handle defensively:JavaScript
Principle 4: model the participation challenge explicitly
Participations are the most operationally challenging part of any Volunteer integration. They have no direct endpoint, they live embedded in two different resources (User detail and Project Date detail), and they change post-event in ways that aren’t always polled correctly.The pattern: dedicated participations table keyed by (project_date_id, user_id)
vomo_disappeared_at column lets you keep history when VOMO removes a participation (organizer correction, Project deletion) — historical reports remain accurate.
Tracking changes to participation fields
Participation fields change over time —checked_in_at is set when the volunteer arrives; hours may be adjusted post-event. For audit-quality reporting, track changes:
Principle 5: denormalize for query, normalize for truth
VOMO’s API returns denormalized data (participations array embedded on UserDetailResource, participants embedded on ProjectDateResource). Don’t blindly mirror this — model normalized canonical data, then denormalize for query-specific views.
Canonical layer (normalized)
Query layer (denormalized)
When to denormalize
| Workload | Strategy |
|---|---|
| One-off ad-hoc queries | Query the normalized tables directly |
| Dashboard with sub-second response needs | Materialized view |
| Per-customer or per-user dashboards (high cardinality, fast response) | Pre-computed per-key summary table |
| Aggregations refreshed many times per day | Incremental materialized view if database supports it |
| Cross-API joins | Joined-store pattern from Combine Volunteer Data with CRM+ Data |
Principle 6: store snapshots for change detection
For change-detection patterns (schedule diffs, member-list diffs), store the previous snapshot so you can compute deltas:Why JSONB for the snapshot
The snapshot isn’t queried — it’s only used for diff comparison. JSONB (or your DB’s JSON type) lets you store the entire schedule structure without designing a normalized schema for it. The diff code parses both old and new JSON into in-memory objects and compares. For PostgreSQL specifically, JSONB has good compression and is reasonably fast. For other databases, consider a serialized format that your application can deserialize cheaply.Principle 7: customer is a top-level dimension
Almost every table should havecustomer_id as part of its primary key or composite key:
Customer-scoped queries everywhere
JavaScript
customerId as the first argument. This makes accidental cross-customer access syntactically harder.
Principle 8: model what VOMO doesn’t expose
Sometimes the most valuable data in your model is data VOMO doesn’t have but the customer needs. Partner-side fields you might add:| Field | Why |
|---|---|
vomo_disappeared_at (per record) | Track when a VOMO record was removed; historical accuracy |
last_communication_at (per person) | Track when the partner integration last reached out |
outreach_status (per person) | The partner-side status: contacted, lapsed, do-not-contact |
partner_notes (per person) | Annotations the customer adds in the partner UI |
derived_segment (per person) | “Donor-volunteer,” “first-timer,” etc. — partner-computed labels |
last_sync_outcome (per record) | Did the most recent sync succeed? |
Anti-patterns to avoid
A few common modeling mistakes:Anti-pattern: storing VOMO’s raw response
Some integrations dump the entire VOMO response as JSON into a single column. This is appealingly simple but creates problems:- Queries on specific fields require JSON path expressions (slow and verbose)
- Schema evolution is invisible — when VOMO adds a field, you don’t know unless you re-process
- Translating snake_case to your conventions has to happen at every read site
Anti-pattern: assuming VOMO IDs are stable
VOMO IDs are stable in normal operation, but partner integrations should defensively handle the possibility that they aren’t:JavaScript
Anti-pattern: not modeling deletions
Many integrations don’t handle deletions explicitly — when a VOMO record disappears, the partner record just sits orphaned. Better:- Detect deletions via reconciliation
- Mark partner records with
vomo_disappeared_atrather than deleting them - Surface “this record no longer exists in VOMO” in the partner UI
- Decide per-record whether to cascade or preserve
Anti-pattern: shared tables across customers
A singleusers table containing all customers’ users sounds efficient but introduces:
- Risk of cross-customer access bugs
- Hard-to-isolate performance issues (one customer’s bulk operation slows everyone)
- Complex deletion when a customer offboards
- Compliance complications (data sovereignty, retention policies)
A reference schema for a typical integration
Putting the principles together:vomo_disappeared_at for soft deletes, audit logging — apply broadly.
Where to go next
API Performance Tips
The caching and performance patterns that make this data model fast.
Error Recovery Patterns
The resilience patterns that protect this data model from API issues.
Sync Architecture Patterns
The broader architectural patterns this modeling fits into.
The Volunteer Data Model
VOMO’s own data model — the source these patterns map from.