Multi-Domain Analytics Platform: Data Model Plan¶
The Universal Abstraction¶
Every domain follows the same structural pattern, with domain-specific semantics:
Entity (student, prospect, bus)
← has attributes from DataSources (Handshake, SIS, routing software)
← has Resources assigned to it (advisor, bus route, case worker)
→ has Outcomes we want to predict (placement, graduation, conversion)
The goal is a schema that makes this universal pattern strongly typed at the core, while allowing domains to define their own semantics without DB migrations.
Example domains: - Career services: Entity=Student, DataSource=Handshake (meetings, employers, applications), Resource=Advisor, Outcome=Placement/Graduation - K-12 transportation: Entity=Student, DataSource=Routing software (routes, stops), Resource=Bus, Outcome=On-time rate / maintenance risk - Nonprofit: Entity=Prospect/Donor, DataSource=CRM, Resource=Case Worker, Outcome=Donor conversion / retention
Layer 1: Core Schema (strongly typed, universal)¶
Organization (existing tenant)
└── Domain
├── EntityType (defines "Student", "Prospect", "Bus")
│ └── Entity (individual records)
├── DataSource (Handshake, SIS, routing software, CRM)
│ └── DataSourceRecord (raw imported events/records)
├── ResourceType (Advisor, Bus Route, Case Worker)
│ └── Resource (individual advisor, specific route)
│ └── ResourceAssignment (Entity ↔ Resource, time-bound)
├── OutcomeDefinition (Placement Rate, Graduation Rate, Conversion)
│ └── OutcomeRecord (observed outcomes per entity)
└── PredictiveModel (trained model metadata)
└── Prediction (per-entity predictions)
Core Models¶
class Domain(OrganizationOwnedModel):
name = models.CharField(max_length=100) # "career_services"
display_name = models.CharField(max_length=200) # "Career Services"
schema = models.JSONField(default=dict) # see Layer 2
class EntityType(OrganizationOwnedModel):
domain = models.ForeignKey(Domain, on_delete=models.CASCADE)
code = models.CharField(max_length=100) # "student", "prospect"
schema = models.JSONField(default=dict) # JSON Schema for attributes
class Entity(OrganizationOwnedModel):
entity_type = models.ForeignKey(EntityType, on_delete=models.CASCADE)
external_id = models.CharField(max_length=255) # ID in source system
attributes = models.JSONField(default=dict) # domain-specific fields
is_active = models.BooleanField(default=True)
class Meta:
unique_together = [("entity_type", "external_id")]
class DataSource(OrganizationOwnedModel):
domain = models.ForeignKey(Domain, on_delete=models.CASCADE)
code = models.CharField(max_length=100) # "handshake", "powerschool"
connector_class = models.CharField(max_length=255) # dotted import path
config = models.JSONField(default=dict) # API keys (encrypted), endpoints
class DataSourceRecord(OrganizationOwnedModel):
source = models.ForeignKey(DataSource, on_delete=models.CASCADE)
entity = models.ForeignKey(Entity, on_delete=models.SET_NULL, null=True)
record_type = models.CharField(max_length=100) # "meeting", "maintenance_event"
external_id = models.CharField(max_length=255)
payload = models.JSONField() # raw source data (immutable)
ingested_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = [("source", "record_type", "external_id")]
class ResourceType(OrganizationOwnedModel):
domain = models.ForeignKey(Domain, on_delete=models.CASCADE)
code = models.CharField(max_length=100) # "advisor", "bus_route"
schema = models.JSONField(default=dict)
class Resource(OrganizationOwnedModel):
resource_type = models.ForeignKey(ResourceType, on_delete=models.CASCADE)
external_id = models.CharField(max_length=255)
attributes = models.JSONField(default=dict)
class ResourceAssignment(OrganizationOwnedModel):
entity = models.ForeignKey(Entity, on_delete=models.CASCADE)
resource = models.ForeignKey(Resource, on_delete=models.CASCADE)
assigned_at = models.DateTimeField()
unassigned_at = models.DateTimeField(null=True) # null = currently active
metadata = models.JSONField(default=dict) # e.g. assignment reason
Layer 2: Domain Schema Registry¶
Each Domain carries a self-describing schema in its schema JSONField. This drives validation, UI form generation, and feature extraction - without DB migrations when domains evolve.
{
"entity_types": {
"student": {
"fields": {
"gpa": {"type": "number"},
"major": {"type": "string"},
"graduation_semester": {"type": "string"},
"first_generation": {"type": "boolean"}
}
}
},
"data_sources": {
"handshake": {
"record_types": {
"meeting": {
"fields": {
"advisor_id": {"type": "string"},
"duration_minutes": {"type": "integer"},
"meeting_type": {"type": "string", "enum": ["career", "resume", "mock_interview"]}
}
},
"application": {
"fields": {
"employer_id": {"type": "string"},
"status": {"type": "string"},
"applied_at": {"type": "string", "format": "date-time"}
}
}
}
}
},
"resource_types": {
"advisor": {
"fields": {
"specialty": {"type": "string"},
"capacity": {"type": "integer"}
}
}
},
"outcomes": {
"placement": {
"label": "Job Placement",
"target_field": "placed",
"target_type": "binary",
"observation_window_days": 180
},
"graduation": {
"label": "Graduation Rate",
"target_field": "graduated_on_time",
"target_type": "binary",
"observation_window_days": 365
}
}
}
The K-12 transportation domain defines a completely different schema (bus, route, maintenance_event records) without touching the database.
A DomainSchemaValidator service validates Entity.attributes and DataSourceRecord.payload against this schema on write.
Layer 3: Predictive Modeling Layer¶
class OutcomeDefinition(OrganizationOwnedModel):
domain = models.ForeignKey(Domain, on_delete=models.CASCADE)
code = models.CharField(max_length=100) # "placement", "graduation"
label = models.CharField(max_length=200)
target_type = models.CharField(max_length=50) # "binary", "regression", "multiclass"
config = models.JSONField(default=dict) # from domain schema
class OutcomeRecord(OrganizationOwnedModel):
entity = models.ForeignKey(Entity, on_delete=models.CASCADE)
outcome = models.ForeignKey(OutcomeDefinition, on_delete=models.CASCADE)
value = models.FloatField() # 0/1 for binary, continuous for regression
observed_at = models.DateTimeField()
metadata = models.JSONField(default=dict)
class FeatureSet(OrganizationOwnedModel):
"""Defines which fields/sources feed into a model's training."""
domain = models.ForeignKey(Domain, on_delete=models.CASCADE)
outcome = models.ForeignKey(OutcomeDefinition, on_delete=models.CASCADE)
feature_definitions = models.JSONField() # see Feature Definitions below
version = models.PositiveIntegerField(default=1)
class PredictiveModel(OrganizationOwnedModel):
feature_set = models.ForeignKey(FeatureSet, on_delete=models.CASCADE)
outcome = models.ForeignKey(OutcomeDefinition, on_delete=models.CASCADE)
model_type = models.CharField(max_length=100) # "gradient_boost", "logistic"
artifact_path = models.CharField(max_length=500) # S3 / storage path
trained_at = models.DateTimeField()
metrics = models.JSONField(default=dict) # AUC, accuracy, F1
is_active = models.BooleanField(default=False) # one active model per outcome
class Prediction(OrganizationOwnedModel):
entity = models.ForeignKey(Entity, on_delete=models.CASCADE)
model = models.ForeignKey(PredictiveModel, on_delete=models.CASCADE)
predicted_value = models.FloatField()
confidence = models.FloatField(null=True)
explanation = models.JSONField(default=dict) # SHAP values, top features
predicted_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = [("entity", "model")]
Feature Definitions¶
FeatureSet.feature_definitions specifies how to build a feature vector from raw data. This avoids hard-coding ETL logic per domain:
{
"entity_attributes": ["gpa", "major", "first_generation"],
"aggregations": [
{
"source": "handshake",
"record_type": "meeting",
"aggregation": "count",
"alias": "meeting_count",
"filters": {"meeting_type": "career"}
},
{
"source": "handshake",
"record_type": "application",
"aggregation": "count",
"alias": "application_count"
},
{
"source": "handshake",
"record_type": "meeting",
"aggregation": "sum",
"field": "duration_minutes",
"alias": "total_meeting_minutes"
}
],
"resource_features": [
{
"resource_type": "advisor",
"feature": "is_assigned",
"alias": "has_advisor"
}
]
}
A FeatureExtractor service reads this config and executes queries against DataSourceRecord + ResourceAssignment to build a feature matrix - domain-agnostically.
Layer 4: Data Pipeline¶
DataSource
└── [Connector] pulls from external API
└── DataSourceRecord (immutable append-only raw records)
└── [EntityResolver] links records to Entity via external_id
└── [AttributeDeriver] updates Entity.attributes from records
└── [FeatureExtractor] builds feature vectors
└── [ModelRunner] scores entities → Prediction
Each stage is domain-agnostic. The domain schema and feature definitions drive behavior.
Connector Interface¶
class BaseConnector:
def __init__(self, data_source: DataSource): ...
def fetch_records(self, since: datetime) -> Iterator[dict]: ...
def get_record_type(self, raw: dict) -> str: ...
def get_external_id(self, raw: dict) -> str: ...
def get_entity_external_id(self, raw: dict) -> str | None: ...
One concrete subclass per integration (HandshakeConnector, PowerSchoolConnector, etc.), registered via connector_class on DataSource.
Indexing Strategy¶
JSONB queries need intentional indexing or they degrade at scale:
# GIN index for arbitrary JSONB key lookups on entity attributes
Index(fields=["attributes"], name="entity_attributes_gin", opclasses=["jsonb_path_ops"])
# For high-frequency filter fields, add generated columns:
# ALTER TABLE entity ADD COLUMN gpa_indexed float GENERATED ALWAYS AS
# ((attributes->>'gpa')::float) STORED;
# Then index that column normally.
# Partial index for active entities per type
Index(fields=["entity_type", "is_active"], condition=Q(is_active=True))
# DataSourceRecord: index by source + record_type for aggregation queries
Index(fields=["source", "record_type", "entity"])
For feature extraction aggregation queries, a materialized view per domain (refreshed on ingestion) makes model training practical at scale.
Adding a New Domain¶
The entire process is configuration, not code:
- Create a
Domainrecord with the schema JSON describing its entity types, data sources, resource types, and outcomes - Register connector classes for each
DataSource - Define
OutcomeDefinitionrecords - Build
FeatureSetdefinitions specifying which fields/aggregations to use - Run the pipeline: ingest → extract features → train → predict
No new models, no new migrations. The schema registry and generic pipeline handle everything.
Key Tradeoffs¶
| Decision | Tradeoff |
|---|---|
| JSONB for attributes | Flexible schema evolution; harder to enforce constraints, slower than typed columns for high-cardinality queries |
Single DataSourceRecord table |
Simple ingestion; very large table at scale - partition by source_id or ingested_at |
Generic FeatureSet config |
Domain-agnostic pipelines; complex aggregations may need escape-hatch custom SQL |
One Prediction table |
Simple; if domains have thousands of entities and many models, partition by model_id |
| Domain schema as JSON | Zero-migration evolution; schema validation is application-layer, not DB-layer |
The EAV anti-pattern is avoided because JSONB with GIN indexing + the domain schema registry gives you the flexibility of EAV with far better query performance and type awareness.