Data models and query languages skill

Choosing a data model is the most consequential architectural decision in an application.

by wondelai·MIT license·★ 2,235 Stars on the repo·GitHub ↗

Use now

Files of Data models and query languages

wondelai/main1 file
data-models.md
Show the full text263 lines

Data Models and Query Languages

Choosing a data model is the most consequential architectural decision in an application. The data model shapes not only how data is stored, but how you think about the problem domain, what queries are natural, and how the system evolves over time.

The Relational Model

When Relational Excels

The relational model, formalized by Edgar Codd in 1970, represents data as tables (relations) of rows (tuples) with typed columns (attributes). Its strength lies in:

  • Many-to-many relationships: Foreign keys and joins make it natural to represent complex relationships without data duplication
  • Ad-hoc queries: SQL's declarative nature lets you ask any question without pre-planned access paths
  • Referential integrity: Foreign key constraints enforce that references point to existing records
  • Transaction support: ACID transactions with mature isolation levels are standard
  • Schema enforcement (schema-on-write): The database rejects data that doesn't conform to the schema, catching errors early
Relational Anti-Patterns

The relational model struggles with:

  • Object-relational impedance mismatch: Application objects don't map cleanly to flat tables; ORMs paper over this but add complexity
  • Deeply nested or tree-structured data: Representing a resume with multiple jobs, each with multiple projects, each with multiple technologies requires many joins
  • Schema rigidity: Adding a column to a table with billions of rows can be operationally expensive (though many databases now support instant ADD COLUMN)
  • Horizontal scaling: Distributing joins across nodes is fundamentally hard
SQL as a Query Language

SQL is declarative: you specify what you want, not how to get it. The optimizer chooses the execution plan, which means:

  • Query performance can improve without changing application code (when the optimizer or indexes improve)
  • Complex queries are concise compared to imperative alternatives
  • The optimizer can parallelize, reorder joins, and choose index strategies
-- Find all users who placed an order in the last 30 days
-- and have a shipping address in California
SELECT DISTINCT u.name, u.email
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN addresses a ON u.id = a.user_id
WHERE o.created_at > NOW() - INTERVAL '30 days'
  AND a.state = 'CA'
  AND a.type = 'shipping';

This query would require nested loops, hash maps, and set operations in imperative code. SQL expresses the intent in six lines.


The Document Model

When Document Excels

Document databases (MongoDB, CouchDB, Firestore) store data as self-contained documents, typically JSON or BSON:

  • One-to-many relationships: When a parent entity contains a list of child entities that are always accessed together, a document model avoids joins entirely
  • Data locality: Reading a single document retrieves all related data in one disk seek, improving read performance for aggregate access
  • Schema flexibility (schema-on-read): Each document can have a different structure; the application interprets the schema at read time
  • Natural fit for aggregates: Domain-driven design aggregates map directly to documents
Document Example
{
  "user_id": "u-4829",
  "name": "Alice Chen",
  "email": "[email protected]",
  "addresses": [
    {"type": "home", "city": "San Francisco", "state": "CA"},
    {"type": "work", "city": "Palo Alto", "state": "CA"}
  ],
  "orders": [
    {
      "order_id": "o-1001",
      "items": [
        {"product": "Keyboard", "qty": 1, "price": 89.99},
        {"product": "Mouse", "qty": 2, "price": 29.99}
      ],
      "total": 149.97
    }
  ]
}

Everything about a user is in one document. No joins needed for the common access pattern of "show me everything about this user."

Document Anti-Patterns
  • Many-to-many relationships: Without joins, you must denormalize (duplicate data) or perform multiple queries and join in application code
  • Cross-document references: If order items reference a shared product catalog, updating a product name requires updating every document that embeds it
  • Large documents: Documents that grow unboundedly (e.g., an array of all user events) cause write amplification because the entire document must be rewritten
  • Deep nesting: Querying deeply nested fields is possible but awkward; updating nested fields requires careful path expressions

The Graph Model

When Graph Excels

Graph databases (Neo4j, Amazon Neptune, JanusGraph) represent data as nodes (entities) and edges (relationships):

  • Highly interconnected data: Social networks, knowledge graphs, fraud detection, recommendation engines
  • Recursive traversals: "Find all people within 3 degrees of connection" is natural in graph query languages but requires recursive CTEs or multiple joins in SQL
  • Heterogeneous data: Nodes and edges can have different types and properties without schema changes
  • Path analysis: Shortest path, centrality, community detection algorithms are built into graph engines
Graph Query Example (Cypher)
// Find mutual friends who live in the same city
MATCH (alice:Person {name: 'Alice'})-[:FRIENDS_WITH]->(mutual)<-[:FRIENDS_WITH]-(bob:Person {name: 'Bob'})
WHERE mutual.city = alice.city
RETURN mutual.name, mutual.city

The equivalent SQL query would require self-joins and subqueries that obscure the intent.

Graph Anti-Patterns
  • Simple CRUD operations: Graph databases add overhead for straightforward create/read/update/delete without relationship traversals
  • Aggregation-heavy analytics: Summing, counting, and grouping are more natural in SQL or column stores
  • Write-heavy workloads: Graph index structures can be slower for bulk ingestion compared to LSM-tree stores

Schema-on-Write vs. Schema-on-Read

Aspect Schema-on-Write (Relational) Schema-on-Read (Document)
When schema is enforced At write time by the database At read time by application code
Error detection Immediate: bad data is rejected Delayed: bad data is stored, fails at read
Schema evolution ALTER TABLE (can be expensive) Just start writing new fields
Data quality Higher: database enforces constraints Lower: application must validate
Flexibility Lower: must define schema upfront Higher: can iterate quickly
Best for Structured data with known schema Semi-structured data with evolving schema
Practical Guidance

Use schema-on-write when:

  • Data integrity is critical (financial, medical, legal)
  • Multiple applications share the same database
  • You need complex queries across the dataset

Use schema-on-read when:

  • The schema is evolving rapidly (early-stage product)
  • Data comes from external sources with varying structure
  • Each record type is accessed as a self-contained unit

Query Languages Compared

SQL (Relational)

Strengths: Mature, standardized, powerful optimizer, excellent tooling. Weaknesses: Verbose for hierarchical data, recursive queries are awkward.

MongoDB Query Language (Document)
db.users.find({
  "addresses.state": "CA",
  "orders.created_at": { $gte: ISODate("2024-01-01") }
})

Strengths: Natural for document traversal, aggregation pipeline is powerful. Weaknesses: No joins (until v3.2 $lookup, still limited), complex aggregations are hard to read.

Cypher (Graph)

Strengths: Pattern matching for relationships, readable path expressions. Weaknesses: Limited ecosystem, fewer tools and integrations.

MapReduce (Batch)
// Word count in MapReduce
map: function() { this.text.split(" ").forEach(w => emit(w, 1)); }
reduce: function(key, values) { return Array.sum(values); }

Strengths: Horizontally scalable, handles massive datasets. Weaknesses: Low-level, hard to compose, high latency.


Data Model Evolution

Adding Fields
  • Relational: ALTER TABLE users ADD COLUMN phone VARCHAR(20); -- all rows get NULL until updated
  • Document: Just start including phone in new documents; old documents simply lack the field
  • Graph: Add a new property to nodes; existing nodes are unaffected
Changing Relationships
  • Relational: Add a junction table for many-to-many; migrate data
  • Document: Restructure documents; may require a migration script for existing data
  • Graph: Add new edge types between existing nodes
Breaking Changes

In all models, renaming or removing fields requires backward-compatible migration:

  1. Expand: Add new field alongside old
  2. Migrate: Backfill new field from old
  3. Contract: Remove old field once all readers use new field

This expand-migrate-contract pattern works regardless of data model.


Polyglot Persistence

Most real-world systems benefit from using multiple data stores, each chosen for its strengths:

Use Case Data Store Reason
Transactional records PostgreSQL ACID, joins, referential integrity
User sessions Redis Sub-millisecond reads, TTL expiration
Full-text search Elasticsearch Inverted indexes, relevance scoring
Activity feed Cassandra High write throughput, time-series partitioning
Recommendation graph Neo4j Relationship traversal, path algorithms
File/blob storage S3 Unlimited capacity, durability
Analytics ClickHouse/BigQuery Column-oriented, fast aggregation
Polyglot Challenges
  • Data consistency: How do you keep PostgreSQL and Elasticsearch in sync? Change data capture (CDC) is the standard answer
  • Operational complexity: Each store requires monitoring, backup, and expertise
  • Query routing: Application must know which store to query for which use case
When to Stay Monoglot

Use a single database when:

  • Your team is small and operational complexity is a bigger risk than suboptimal performance
  • Your data fits comfortably in one model (most CRUD apps)
  • Your query patterns are uniform (all point lookups, or all analytical scans)

Add a second store only when you have measured evidence that the current store cannot serve a specific access pattern.


Data Model Migration Patterns

Relational to Document

Common when an application starts with a relational database but finds that most queries fetch entire aggregate objects (user profiles, product listings) rather than joining across tables. The migration pattern:

  1. Identify aggregates that are always fetched together
  2. Denormalize related tables into nested document structures
  3. Accept data duplication for fields that are shared across aggregates (e.g., category names stored in both the category table and embedded in product documents)
  4. Maintain a relational database for data that genuinely requires joins and referential integrity
Document to Relational

Common when an application starts with a document database but discovers increasing need for cross-document queries, reporting, or referential integrity. Warning signs include frequent application-level joins, growing inconsistency from denormalized data, and complex aggregation queries that fight the document model.

Adding a Graph Layer

When relationships between entities become a first-class concept (recommendations, fraud detection, knowledge graphs), adding a graph database alongside existing stores is often more practical than migrating. The graph database handles traversal queries while the primary store handles CRUD operations. Data synchronization between the stores is typically handled through CDC or periodic ETL jobs.

1# Data Models and Query Languages
2 
3Choosing a data model is the most consequential architectural decision in an application. The data model shapes not only how data is stored, but how you think about the problem domain, what queries are natural, and how the system evolves over time.
4 
5## The Relational Model
6 
7### When Relational Excels
8 
9The relational model, formalized by Edgar Codd in 1970, represents data as tables (relations) of rows (tuples) with typed columns (attributes). Its strength lies in:
10 
11- **Many-to-many relationships:** Foreign keys and joins make it natural to represent complex relationships without data duplication
12- **Ad-hoc queries:** SQL's declarative nature lets you ask any question without pre-planned access paths
13- **Referential integrity:** Foreign key constraints enforce that references point to existing records
14- **Transaction support:** ACID transactions with mature isolation levels are standard
15- **Schema enforcement (schema-on-write):** The database rejects data that doesn't conform to the schema, catching errors early
16 
17### Relational Anti-Patterns
18 
19The relational model struggles with:
20 
21- **Object-relational impedance mismatch:** Application objects don't map cleanly to flat tables; ORMs paper over this but add complexity
22- **Deeply nested or tree-structured data:** Representing a resume with multiple jobs, each with multiple projects, each with multiple technologies requires many joins
23- **Schema rigidity:** Adding a column to a table with billions of rows can be operationally expensive (though many databases now support instant `ADD COLUMN`)
24- **Horizontal scaling:** Distributing joins across nodes is fundamentally hard
25 
26### SQL as a Query Language
27 
28SQL is declarative: you specify what you want, not how to get it. The optimizer chooses the execution plan, which means:
29 
30- Query performance can improve without changing application code (when the optimizer or indexes improve)
31- Complex queries are concise compared to imperative alternatives
32- The optimizer can parallelize, reorder joins, and choose index strategies
33 
34```sql
35-- Find all users who placed an order in the last 30 days
36-- and have a shipping address in California
37SELECT DISTINCT u.name, u.email
38FROM users u
39JOIN orders o ON u.id = o.user_id
40JOIN addresses a ON u.id = a.user_id
41WHERE o.created_at > NOW() - INTERVAL '30 days'
42 AND a.state = 'CA'
43 AND a.type = 'shipping';
44```
45 
46This query would require nested loops, hash maps, and set operations in imperative code. SQL expresses the intent in six lines.
47 
48---
49 
50## The Document Model
51 
52### When Document Excels
53 
54Document databases (MongoDB, CouchDB, Firestore) store data as self-contained documents, typically JSON or BSON:
55 
56- **One-to-many relationships:** When a parent entity contains a list of child entities that are always accessed together, a document model avoids joins entirely
57- **Data locality:** Reading a single document retrieves all related data in one disk seek, improving read performance for aggregate access
58- **Schema flexibility (schema-on-read):** Each document can have a different structure; the application interprets the schema at read time
59- **Natural fit for aggregates:** Domain-driven design aggregates map directly to documents
60 
61### Document Example
62 
63```json
64{
65 "user_id": "u-4829",
66 "name": "Alice Chen",
67 "email": "[email protected]",
68 "addresses": [
69 {"type": "home", "city": "San Francisco", "state": "CA"},
70 {"type": "work", "city": "Palo Alto", "state": "CA"}
71 ],
72 "orders": [
73 {
74 "order_id": "o-1001",
75 "items": [
76 {"product": "Keyboard", "qty": 1, "price": 89.99},
77 {"product": "Mouse", "qty": 2, "price": 29.99}
78 ],
79 "total": 149.97
80 }
81 ]
82}
83```
84 
85Everything about a user is in one document. No joins needed for the common access pattern of "show me everything about this user."
86 
87### Document Anti-Patterns
88 
89- **Many-to-many relationships:** Without joins, you must denormalize (duplicate data) or perform multiple queries and join in application code
90- **Cross-document references:** If order items reference a shared product catalog, updating a product name requires updating every document that embeds it
91- **Large documents:** Documents that grow unboundedly (e.g., an array of all user events) cause write amplification because the entire document must be rewritten
92- **Deep nesting:** Querying deeply nested fields is possible but awkward; updating nested fields requires careful path expressions
93 
94---
95 
96## The Graph Model
97 
98### When Graph Excels
99 
100Graph databases (Neo4j, Amazon Neptune, JanusGraph) represent data as nodes (entities) and edges (relationships):
101 
102- **Highly interconnected data:** Social networks, knowledge graphs, fraud detection, recommendation engines
103- **Recursive traversals:** "Find all people within 3 degrees of connection" is natural in graph query languages but requires recursive CTEs or multiple joins in SQL
104- **Heterogeneous data:** Nodes and edges can have different types and properties without schema changes
105- **Path analysis:** Shortest path, centrality, community detection algorithms are built into graph engines
106 
107### Graph Query Example (Cypher)
108 
109```cypher
110// Find mutual friends who live in the same city
111MATCH (alice:Person {name: 'Alice'})-[:FRIENDS_WITH]->(mutual)<-[:FRIENDS_WITH]-(bob:Person {name: 'Bob'})
112WHERE mutual.city = alice.city
113RETURN mutual.name, mutual.city
114```
115 
116The equivalent SQL query would require self-joins and subqueries that obscure the intent.
117 
118### Graph Anti-Patterns
119 
120- **Simple CRUD operations:** Graph databases add overhead for straightforward create/read/update/delete without relationship traversals
121- **Aggregation-heavy analytics:** Summing, counting, and grouping are more natural in SQL or column stores
122- **Write-heavy workloads:** Graph index structures can be slower for bulk ingestion compared to LSM-tree stores
123 
124---
125 
126## Schema-on-Write vs. Schema-on-Read
127 
128| Aspect | Schema-on-Write (Relational) | Schema-on-Read (Document) |
129|--------|------------------------------|---------------------------|
130| **When schema is enforced** | At write time by the database | At read time by application code |
131| **Error detection** | Immediate: bad data is rejected | Delayed: bad data is stored, fails at read |
132| **Schema evolution** | ALTER TABLE (can be expensive) | Just start writing new fields |
133| **Data quality** | Higher: database enforces constraints | Lower: application must validate |
134| **Flexibility** | Lower: must define schema upfront | Higher: can iterate quickly |
135| **Best for** | Structured data with known schema | Semi-structured data with evolving schema |
136 
137### Practical Guidance
138 
139Use schema-on-write when:
140- Data integrity is critical (financial, medical, legal)
141- Multiple applications share the same database
142- You need complex queries across the dataset
143 
144Use schema-on-read when:
145- The schema is evolving rapidly (early-stage product)
146- Data comes from external sources with varying structure
147- Each record type is accessed as a self-contained unit
148 
149---
150 
151## Query Languages Compared
152 
153### SQL (Relational)
154 
155Strengths: Mature, standardized, powerful optimizer, excellent tooling.
156Weaknesses: Verbose for hierarchical data, recursive queries are awkward.
157 
158### MongoDB Query Language (Document)
159 
160```javascript
161db.users.find({
162 "addresses.state": "CA",
163 "orders.created_at": { $gte: ISODate("2024-01-01") }
164})
165```
166 
167Strengths: Natural for document traversal, aggregation pipeline is powerful.
168Weaknesses: No joins (until v3.2 $lookup, still limited), complex aggregations are hard to read.
169 
170### Cypher (Graph)
171 
172Strengths: Pattern matching for relationships, readable path expressions.
173Weaknesses: Limited ecosystem, fewer tools and integrations.
174 
175### MapReduce (Batch)
176 
177```javascript
178// Word count in MapReduce
179map: function() { this.text.split(" ").forEach(w => emit(w, 1)); }
180reduce: function(key, values) { return Array.sum(values); }
181```
182 
183Strengths: Horizontally scalable, handles massive datasets.
184Weaknesses: Low-level, hard to compose, high latency.
185 
186---
187 
188## Data Model Evolution
189 
190### Adding Fields
191 
192- **Relational:** `ALTER TABLE users ADD COLUMN phone VARCHAR(20);` -- all rows get NULL until updated
193- **Document:** Just start including `phone` in new documents; old documents simply lack the field
194- **Graph:** Add a new property to nodes; existing nodes are unaffected
195 
196### Changing Relationships
197 
198- **Relational:** Add a junction table for many-to-many; migrate data
199- **Document:** Restructure documents; may require a migration script for existing data
200- **Graph:** Add new edge types between existing nodes
201 
202### Breaking Changes
203 
204In all models, renaming or removing fields requires backward-compatible migration:
205 
2061. **Expand:** Add new field alongside old
2072. **Migrate:** Backfill new field from old
2083. **Contract:** Remove old field once all readers use new field
209 
210This expand-migrate-contract pattern works regardless of data model.
211 
212---
213 
214## Polyglot Persistence
215 
216Most real-world systems benefit from using multiple data stores, each chosen for its strengths:
217 
218| Use Case | Data Store | Reason |
219|----------|-----------|--------|
220| **Transactional records** | PostgreSQL | ACID, joins, referential integrity |
221| **User sessions** | Redis | Sub-millisecond reads, TTL expiration |
222| **Full-text search** | Elasticsearch | Inverted indexes, relevance scoring |
223| **Activity feed** | Cassandra | High write throughput, time-series partitioning |
224| **Recommendation graph** | Neo4j | Relationship traversal, path algorithms |
225| **File/blob storage** | S3 | Unlimited capacity, durability |
226| **Analytics** | ClickHouse/BigQuery | Column-oriented, fast aggregation |
227 
228### Polyglot Challenges
229 
230- **Data consistency:** How do you keep PostgreSQL and Elasticsearch in sync? Change data capture (CDC) is the standard answer
231- **Operational complexity:** Each store requires monitoring, backup, and expertise
232- **Query routing:** Application must know which store to query for which use case
233 
234### When to Stay Monoglot
235 
236Use a single database when:
237- Your team is small and operational complexity is a bigger risk than suboptimal performance
238- Your data fits comfortably in one model (most CRUD apps)
239- Your query patterns are uniform (all point lookups, or all analytical scans)
240 
241Add a second store only when you have measured evidence that the current store cannot serve a specific access pattern.
242 
243---
244 
245## Data Model Migration Patterns
246 
247### Relational to Document
248 
249Common when an application starts with a relational database but finds that most queries fetch entire aggregate objects (user profiles, product listings) rather than joining across tables. The migration pattern:
250 
2511. Identify aggregates that are always fetched together
2522. Denormalize related tables into nested document structures
2533. Accept data duplication for fields that are shared across aggregates (e.g., category names stored in both the category table and embedded in product documents)
2544. Maintain a relational database for data that genuinely requires joins and referential integrity
255 
256### Document to Relational
257 
258Common when an application starts with a document database but discovers increasing need for cross-document queries, reporting, or referential integrity. Warning signs include frequent application-level joins, growing inconsistency from denormalized data, and complex aggregation queries that fight the document model.
259 
260### Adding a Graph Layer
261 
262When relationships between entities become a first-class concept (recommendations, fraud detection, knowledge graphs), adding a graph database alongside existing stores is often more practical than migrating. The graph database handles traversal queries while the primary store handles CRUD operations. Data synchronization between the stores is typically handled through CDC or periodic ETL jobs.
263 

Discussion