The previous lesson ended with a list of three failures: the GPS telemetry of every trip, the enriched station profile and the incidents with a variable structure. Three things VallBici's relational schema handles badly, not because it is badly designed, but because they are data that do not ask for what the relational model knows how to give.
This lesson takes those three exact pieces —not one more— and models them in MongoDB applying the query-driven design method from 03-03. It does not redesign the transactional core: subscriptions, trips, charges, docks and bicycles stay in PostgreSQL, and the first part of the lesson explains why that boundary is the most important decision in the whole chapter.
At the end, each design decision is compared with how it turned out in 08-01, what has been lost is listed precisely, and the alternative an experienced professional would raise in the meeting is put on the table: and why don't we do it all in PostgreSQL with jsonb and PostGIS? It is a legitimate question and it deserves an answer with arguments, not a shrug.
And it ends with an open problem we will not know how to solve yet: from today on there will be two databases holding information about the same bicycle, and nobody has said which one wins.
Contents
- What leaves PostgreSQL and why not everything
- The method: queries first, documents afterwards
- The
stationscollection: the enriched profile - The
trips_telemetrycollection: the bucket pattern - The
incidentscollection: variable structure with validation - Write operations: real-time telemetry
- Aggregation pipelines: the operations reports
- Geospatial queries: what the relational side did worse
- Indexes,
explain()and expiry with TTL - What has been lost and how it is mitigated
- The honest comparison: PostgreSQL with
jsonband PostGIS - Common Mistakes and Tips
- Exercises
- Conclusion
- What leaves PostgreSQL and why not everything
Let us start with what is not done, because that is where most people get it wrong.
The wrong decision is "we migrate VallBici to MongoDB".
If somebody proposes it in the meeting, these are the concrete —not generic— arguments against it in this system:
- Billing must be ACID and multi-document. Closing a trip touches four things: the trip, the dock, the bicycle and the charge. In PostgreSQL it is one transaction and there is nothing more to say. MongoDB has had multi-document transactions since 4.0 (we saw it in 03-04), but they carry a noticeable performance cost and are the exception, not the normal working mode. Building a billing system on top of an engine's exception is a bad choice.
- The constraints that hold VallBici up are declarative.
EXCLUDE USING gistfor overlapping subscriptions, the partial unique index for open trips, the composite foreign key of the hierarchy discriminant. MongoDB has none of the three. They would be application code, and we already saw in 08-01 what happens to rules that depend on code under concurrency. - The city council's queries are unpredictable. A document model is designed to answer a known set of queries fast. Next year the council will ask for a cross-tabulation nobody has imagined today, and there the normalized schema wins hands down: it is the virtue 03-03 highlighted in the relational model, answering questions nobody foresaw.
- There is no scale problem in the core. 24,000 subscribers and 1.6 million trips a year fit comfortably in a single-server PostgreSQL. The sharding from 03-01 solves a problem VallBici does not have.
The criterion for what does leave
A dataset is a candidate to leave the relational world when it meets several of these conditions. None is enough on its own:
| Condition | Telemetry | Station profile | Incidents | Trips (core) |
|---|---|---|---|---|
| Structure varies between records | No | Yes | Yes | No |
| Very high write volume | Yes (272 M/year) | No | No | Medium |
| Always read as a whole, by its identifier | Yes | Yes | Yes | No |
| Does not take part in money transactions | Yes | Yes | Yes | No |
| Does not need strict referential integrity | Yes | Yes | Partial | No |
| Expires and is deleted in bulk | Yes | No | No | No |
| Known and stable queries | Yes | Yes | Yes | No |
| Verdict | Leaves | Leaves | Leaves | Stays |
The first three columns meet five or more conditions; the fourth meets practically none. The boundary is not ideological: it is this table.
flowchart LR
subgraph pg["PostgreSQL · stays as it is (08-01)"]
direction TB
P1["subscriptions · trips · charges"]
P2["bicycles · docks · stations"]
P3["workshop_orders (accounting fact)"]
end
subgraph mg["MongoDB · the three pieces that leave"]
direction TB
M1["stations<br/>enriched profile"]
M2["trips_telemetry<br/>bucket pattern"]
M3["incidents<br/>variable detail"]
end
P2 -. "station_id as _id" .-> M1
P1 -. "trip_id as _id" .-> M2
P3 -. "workshop_order_id" .-> M3
P2 -. "plate and model<br/>copied (extended reference)" .-> M2
The dashed arrows are not foreign keys: they are agreements. They all point the same way and none of them is enforced by an engine. Who maintains them, with what delay and what happens when they fail is the entire content of lesson 08-03.
One important nuance about the incidents. In 08-01 there is workshop_orders, with its cost NUMERIC and its unique open-order index. That table stays: it is the workshop's accounting record and it feeds into the municipal budget. What leaves is the detailed report of the incident —the variable structure with photos, measurements and observations—, which will live in MongoDB referencing the order. That distinction between "the accounting fact" and "the heterogeneous detail of the fact" is one of the most useful splits there is, and it applies to a great many domains.
- The method: queries first, documents afterwards
In 03-03 we inverted the mental order of module 2: in the document model you do not start from the domain entities, you start from the queries. So before writing any document, here is the list, with its frequency and its latency requirement.
| # | Query | Who | Frequency | Latency |
|---|---|---|---|---|
| Q1 | Full profile of a station for the app | Mobile app | 60,000/day | < 50 ms |
| Q2 | Stations less than 500 m from my position | Mobile app | 40,000/day | < 80 ms |
| Q3 | Stations in a district with filters (accessible, covered, with a pump) | Mobile app | 8,000/day | < 100 ms |
| Q4 | The whole GPS trace of a trip | Support / map | 2,000/day | < 200 ms |
| Q5 | Total distance covered by a bicycle in a period | Operations | 200/day | < 1 s |
| Q6 | Heat map of routes by district | Operations | 30/day | < 10 s |
| Q7 | Open incidents of a type, with their detail | Workshop | 500/day | < 200 ms |
| Q8 | Incidents by bicycle model, last 90 days | Operations | 20/day | < 5 s |
| Q9 | Battery series of an electric bike during a trip | Workshop | 100/day | < 300 ms |
Two observations before designing:
- Q1, Q4, Q7 and Q9 read a whole object by its identifier. They are the ones the document model serves with a single disk access, and they are 95% of the volume. That figure alone justifies the decision.
- Q5, Q6 and Q8 are analytical aggregations. There are few of them and they tolerate seconds. The documents do not have to be designed for them; you just have to make sure they can be answered.
The rule from 03-03: design for the frequent queries, tolerate the rare ones.
- The
stations collection: the enriched profile
stations collection: the enriched profiledb.stations.insertOne({
_id: 12, // the SAME station_id from PostgreSQL. See note below.
code: "VB-012",
name: "Station 12 · North Wharf",
address: "14 Wharf Promenade",
district: { id: 1, name: "Harbor" }, // extended reference: id + whatever is displayed
location: { // GeoJSON: mandatory for the 2dsphere index
type: "Point",
coordinates: [-3.200000, 40.116000] // longitude first! That is the GeoJSON order
},
capacity: { docks: 24, covered: true },
accessibility: {
ramp: true,
passage_width_cm: 120,
pavement: "cobblestone",
notes: "4 cm curb at the south access"
},
services: ["air_pump", "info_panel", "electric_charging"],
opening_hours: { // varies by station: there is no fixed schema here
type: "restricted",
opening: "06:00", closing: "01:00",
exceptions: [{ date: "2026-09-08", reason: "Harbor Festival", closed: true }]
},
photos: [
{ url: "https://cdn.vallmar.example/st/012-1.webp", type: "general", alt: "General view" },
{ url: "https://cdn.vallmar.example/st/012-2.webp", type: "access", alt: "South access" }
],
nearby_poi: ["Ferry Terminal", "Harbor Market"],
maintainer: { company: "Serveis Vallmar SL", contract: "2026-014", phone: "900 000 000" },
schema_v: 2, // document versioning (03-03)
updated_at: ISODate("2026-06-14T09:12:00Z")
});The decisions, one by one
_id is PostgreSQL's station_id, not an ObjectId. It is the most consequential decision in the document. Advantages: joining the two worlds is trivial, no extra index over a station_id field is needed, and a repeated write with the same _id is naturally idempotent. Drawback: it forces the station to exist in PostgreSQL first. That is accepted, because PostgreSQL is the source of the truth for the inventory — and that sentence, which sounds innocent here, is the whole subject of 08-03.
district embedded as a subdocument with id and name: extended reference pattern. The district name is displayed on the profile; going to fetch it from another collection for two words makes no sense. Only what is displayed is copied, not the district's whole document. The duplication risk is the one 03-03 warned about: if the council renames a district you have to update 60 documents. With five districts that change name approximately never, that is an acceptable risk and the corresponding updateMany is one line.
photos embedded, not referenced. We apply the criteria from 03-03: there are few of them (2-6 per station), they are always read with the station, they are never queried separately and they do not grow without limit. It is a textbook "one to few". Embed.
accessibility and opening_hours as free-form subdocuments. Here is the reason this collection exists. In the relational world, pavement: "cobblestone" and passage_width_cm: 120 would be two more columns that only have a value at some stations. Here, the station with no ramp simply does not carry the field, and adding direct_bike_lane: true tomorrow requires no migration at all.
What is NOT embedded: the bicycles present. The temptation is enormous —"that way the app asks for one document and has everything"— and it is one of the four anti-patterns 03-03 flagged: an array that changes dozens of times an hour inside a document that is read 60,000 times a day. Every unlock would rewrite the whole document, invalidating the cache and causing contention. Real-time availability does not live here; in 08-03 you will see where it lives.
Comparison with 08-01
| Aspect | PostgreSQL (08-01) | MongoDB (here) |
|---|---|---|
| Optional attributes | Null columns or EAV | Absent fields, at no cost |
Adding direct_bike_lane |
ALTER TABLE in production |
Write the field |
| Photos | station_photos table + JOIN |
Embedded array |
| Reading the full profile | 3-4 JOINs |
One findOne |
| "Stations with more than 20 docks" | Trivial and indexed | Trivial and indexed |
| "Stations without a ramp" | Trivial | Requires thinking about the absent field |
That last row is the honest downside: in the document world, "it has no ramp" and "we do not know whether it has a ramp" look dangerously alike. It is solved with schema validation, which we will see in the incidents collection.
- The
trips_telemetry collection: the bucket pattern
trips_telemetry collection: the bucket patternThis is the case where the choice of structure changes the system by two orders of magnitude, so let us first do the calculation that rules out the naive option.
Why one document per GPS point is an anti-pattern
An average trip lasts 14 minutes with a position every 5 seconds: 168 points. With 1.6 million trips a year:
| Strategy | Documents/year | Document size | _id + index overhead |
Reading a trace (Q4) |
|---|---|---|---|---|
| One document per point | 269 million | ~90 B useful, ~200 B real | ≈ 32 GB in indexes alone | 168 documents, 168 index entries |
| One document per trip (bucket) | 1.6 million | ~14 KB | ≈ 190 MB in indexes | 1 document, 1 access |
One document per point multiplies the number of documents by 168, multiplies the index cost by more than 100 and turns the most frequent query over this data into the retrieval of 168 objects that have to be sorted. It is the anti-pattern 03-03 called the too-small document: the metadata weighs more than the data.
The chosen design
db.trips_telemetry.insertOne({
_id: NumberLong(884213), // PostgreSQL's trip_id
bicycle: { id: 417, plate: "VB-0417", type: "electric", model: "Ciclmar E-Vall" },
origin_station: 12,
start_ts: ISODate("2026-06-14T06:12:04Z"),
end_ts: ISODate("2026-06-14T06:26:31Z"),
window: 0, // 0 = normal trip; see overflow below
point_count: 168,
distance_m: 3402,
bbox: { min: [-3.2041, 40.1102], max: [-3.1908, 40.1194] }, // computed field
battery: { start_pct: 88, end_pct: 79 },
points: [
{ t: 0, p: [-3.2000, 40.1160], v: 0.0, b: 88 }, // t = seconds since start_ts
{ t: 5, p: [-3.2003, 40.1163], v: 3.2, b: 88 },
{ t: 10, p: [-3.2008, 40.1168], v: 5.1, b: 88 },
// ... 165 more
{ t: 867, p: [-3.1908, 40.1102], v: 0.0, b: 79 }
],
events: [
{ t: 412, type: "hard_braking", g: 0.42 },
{ t: 690, type: "lane_departure" }
],
schema_v: 1
});Short field names: t, p, v, b. It is not affectation. MongoDB stores the name of every field in every element of the array. With timestamp, position, speed and battery, the names would weigh about 30 bytes per point × 168 points × 1.6 M trips ≈ 8 GB a year just repeating words. With one-letter names, about 900 MB. It is one of the very few situations in which sacrificing field readability is justified, and the trade-off is documenting it well.
t is an offset in seconds, not a date. An ISODate takes 8 bytes and a small integer 1 or 2. The absolute date is reconstructed by adding it to start_ts. Same reasoning.
bbox and distance_m are computed fields (the computed-field pattern from 03-03). They are filled in when the trip is closed and avoid having to walk the 168 points every time somebody asks "how far did it go?". It is the same logic as available_bikes in 08-01: you pay with a redundancy that has to be maintained and you collect on every read.
bicycle embedded with four fields: extended reference again. Why copy the plate and the model if they are in PostgreSQL? Because Q8 —incidents by model— and Q5 —distance by bicycle— would otherwise be resolved with an impossible $lookup: the bicycles table is in another engine. Copying the model here is what makes it possible to group by model without leaving MongoDB. The price: if a bike changes model (it does not happen) or its plate is corrected (it happens rarely), there are historical documents with the old value. For historical data that is correct, not an error: the trip was made with the plate it had at the time. It is exactly the same argument as the frozen fare in 08-01.
The overflow: window
The hard limit of a MongoDB document is 16 MB. A normal trip takes 14 KB, so there is room to spare. But VallBici has a rare case: the weekend tourist route, with five-hour trips. 5 h × 720 points/h = 3,600 points ≈ 300 KB. It still fits, but a pathological trip —a bike left in a truck for two days with the GPS transmitting— could get close to the limit.
The operating rule, applying the bucket pattern with a ceiling:
Maximum 900 points per document (75 minutes). When that is exceeded, a new document is opened with
window: 1,window: 2…
Q4 then goes from being a findOne to a find with a sort, but only for the 0.3% of trips that overflow. It is the literal application of the outlier pattern from 03-03: you do not design the whole system for the rare case; you design for the normal case and flag the rare one.
- The
incidents collection: variable structure with validation
incidents collection: variable structure with validation// Brakes incident
{
_id: ObjectId("665a1f3c8e4b2a0012ab34cd"),
workshop_order_id: NumberLong(30412), // reference to workshop_orders, in PostgreSQL
bicycle: { id: 417, plate: "VB-0417", type: "mechanical", model: "Norvent Urban2" },
type: "brakes",
status: "open",
severity: 3,
reported_by: { channel: "user_app", subscription_id: 10233 },
opened_at: ISODate("2026-06-14T07:02:11Z"),
station: 12,
detail: { // ← free-form: depends on "type"
brake: "rear",
pad_mm: 1.2,
cable_slack_mm: 6,
braking_test_m: 8.4
},
schema_v: 1
}
// Battery incident: SAME kind of document, completely different "detail"
{
_id: ObjectId("665a1f3c8e4b2a0012ab34ce"),
workshop_order_id: NumberLong(30413),
bicycle: { id: 512, plate: "VB-0512", type: "electric", model: "Ciclmar E-Vall" },
type: "battery",
status: "in_workshop",
severity: 4,
reported_by: { channel: "telemetry" },
opened_at: ISODate("2026-06-14T09:41:00Z"),
detail: {
charge_cycles: 812,
voltage_v: 33.1,
remaining_capacity_pct: 61,
serial: "BT-2024-00871",
faulty_cells: [3, 7]
}
}
// Vandalism incident
{
_id: ObjectId("665a1f3c8e4b2a0012ab34cf"),
workshop_order_id: NumberLong(30414),
bicycle: { id: 733, plate: "VB-0733", type: "electric", model: "Ciclmar E-Vall" },
type: "vandalism",
status: "open",
severity: 5,
reported_by: { channel: "operator", operator: "JMR" },
opened_at: ISODate("2026-06-13T22:15:00Z"),
detail: {
police_report: "PL-2026-4471",
affected_parts: ["saddle", "frame", "display"],
photos: ["https://cdn.vallmar.example/inc/4471-1.webp"],
estimated_cost_eur: 240
}
}The $jsonSchema: validate what is common, leave what is specific free
The balance 03-03 argued for. Without validation, MongoDB accepts anything and six months later there are documents with type: "Brakes", type: "brake" and severity: "high". With excessive validation, you lose the only advantage that justified using MongoDB.
db.createCollection("incidents", {
validator: { $jsonSchema: {
bsonType: "object",
required: ["workshop_order_id", "bicycle", "type", "status", "severity", "opened_at"],
properties: {
workshop_order_id: { bsonType: "long" },
bicycle: {
bsonType: "object",
required: ["id", "plate", "type", "model"],
properties: {
id: { bsonType: "int" },
plate: { bsonType: "string", pattern: "^VB-[0-9]{4}$" },
type: { enum: ["mechanical", "electric"] },
model: { bsonType: "string" }
}
},
type: { enum: ["brakes","battery","wheel","electronics","vandalism","other"] },
status: { enum: ["open","in_workshop","resolved","discarded"] },
severity: { bsonType: "int", minimum: 1, maximum: 5 },
opened_at: { bsonType: "date" },
detail: { bsonType: "object" } // ← an object, and nothing more. Deliberate.
}
}},
validationLevel: "strict",
validationAction: "error"
});The key line is detail: { bsonType: "object" }. It is required to be an object —not a string or an array— and absolutely nothing is said about its contents. Adding type: "gps" tomorrow with a detail of three new fields requires one single thing: adding "gps" to the enum. No ALTER TABLE, no downtime window.
Proof that it works:
db.incidents.insertOne({ workshop_order_id: NumberLong(1), type: "brakes",
status: "open", severity: 9, opened_at: new Date(),
bicycle: { id: 417, plate: "VB-0417", type: "mechanical", model: "Norvent Urban2" }});MongoServerError: Document failed validation
Additional information: {
failingDocumentId: ObjectId('...'),
details: { operatorName: '$jsonSchema',
schemaRulesNotSatisfied: [ { operatorName: 'properties',
propertiesNotSatisfied: [ { propertyName: 'severity',
details: [ { operatorName: 'maximum', specifiedAs: { maximum: 5 },
reason: 'comparison failed', consideredValue: 9 } ] } ] } ] }
}Compared with 08-01: there, severity BETWEEN 1 AND 5 would have been a twenty-character CHECK. Here it is twelve lines of JSON. Validation in the document world costs more to write and you have to want it explicitly, and that is why so many collections end up with none at all.
- Write operations: real-time telemetry
The bicycle emits a position every 5 seconds. Is every point written the moment it arrives?
// Option A — one $push per point. 269 million writes a year.
db.trips_telemetry.updateOne(
{ _id: NumberLong(884213) },
{ $push: { points: { t: 415, p: [-3.1991, 40.1151], v: 4.8, b: 84 } },
$inc: { point_count: 1 } }
);It works, but it has two measurable problems. The first: every $push rewrites the document if it has grown beyond the reserved space, and a document that grows from 200 B to 14 KB in 168 steps is relocated several times. The second: it is 269 million network and WAL operations (here, journal operations) a year.
The chosen option is the micro-batch in the telemetry gateway: the application accumulates 30 seconds' worth of points in memory and writes six at once.
db.trips_telemetry.updateOne(
{ _id: NumberLong(884213) },
{ $push: { points: { $each: [
{ t: 415, p: [-3.1991, 40.1151], v: 4.8, b: 84 },
{ t: 420, p: [-3.1988, 40.1148], v: 5.0, b: 84 },
{ t: 425, p: [-3.1984, 40.1145], v: 5.1, b: 84 },
{ t: 430, p: [-3.1980, 40.1141], v: 4.9, b: 84 },
{ t: 435, p: [-3.1977, 40.1138], v: 4.7, b: 83 },
{ t: 440, p: [-3.1974, 40.1134], v: 4.4, b: 83 }
], $slice: 900 } }, // ← the bucket ceiling, enforced by the engine
$inc: { point_count: 6 },
$max: { last_t: 440 }
},
{ upsert: true }
);The four details that matter:
$eachturns six writes into one. It reduces the number of operations by a factor of 6 and the risk of the document being relocated.$slice: 900is the bucket ceiling enforced by the engine itself: the array never goes beyond 900 elements, whatever the application does. It is the document-world equivalent of aCHECK.$max: { last_t: 440 }makes the write order-idempotent: if a batch arrives late and out of order over the network,last_tdoes not go backwards.upsert: truelets the first batch create the document. If the "trip started" event were lost, the telemetry is not lost with it.
What $push does not fix, and it has to be said: $each is not idempotent. If the gateway retries a batch because it did not get an acknowledgment, the six points are added twice. With position data that is tolerable —two identical consecutive points do not change any conclusion— and that is why it is accepted. If they were monetary amounts, it would not be, and that is exactly the reason the money stayed in PostgreSQL.
Closing the trip
db.trips_telemetry.updateOne(
{ _id: NumberLong(884213) },
[ { $set: {
end_ts: "$$NOW",
point_count: { $size: "$points" },
bbox: {
min: [ { $min: { $map: { input: "$points", in: { $arrayElemAt: ["$$this.p", 0] } } } },
{ $min: { $map: { input: "$points", in: { $arrayElemAt: ["$$this.p", 1] } } } } ],
max: [ { $max: { $map: { input: "$points", in: { $arrayElemAt: ["$$this.p", 0] } } } },
{ $max: { $map: { input: "$points", in: { $arrayElemAt: ["$$this.p", 1] } } } } ]
},
"battery.end_pct": { $last: "$points.b" }
} } ]
);It is an update with an aggregation pipeline (available since MongoDB 4.2): it lets you compute the derived fields inside the engine, without pulling the 168 points into the application. It is the document-world equivalent of a generated column.
- Aggregation pipelines: the operations reports
Q5 — Distance covered by bicycle in a period
db.trips_telemetry.aggregate([
{ $match: { start_ts: { $gte: ISODate("2026-06-01"), $lt: ISODate("2026-07-01") } } },
{ $group: { _id: { id: "$bicycle.id", plate: "$bicycle.plate",
model: "$bicycle.model" },
km: { $sum: { $divide: ["$distance_m", 1000] } },
trips: { $sum: 1 },
avg_km: { $avg: { $divide: ["$distance_m", 1000] } } } },
{ $sort: { km: -1 } },
{ $limit: 5 },
{ $project: { _id: 0, plate: "$_id.plate", model: "$_id.model",
km: { $round: ["$km", 1] }, trips: 1,
avg_km: { $round: ["$avg_km", 2] } } }
]);[
{ trips: 214, plate: 'VB-0512', model: 'Ciclmar E-Vall', km: 741.2, avg_km: 3.46 },
{ trips: 198, plate: 'VB-0733', model: 'Ciclmar E-Vall', km: 688.4, avg_km: 3.48 },
{ trips: 231, plate: 'VB-0417', model: 'Norvent Urban2', km: 655.9, avg_km: 2.84 },
{ trips: 205, plate: 'VB-0388', model: 'Norvent Urban2', km: 601.3, avg_km: 2.93 },
{ trips: 187, plate: 'VB-0041', model: 'Norvent Urban2', km: 573.0, avg_km: 3.06 }
]Stage by stage:
| Stage | What it does | SQL equivalent |
|---|---|---|
$match |
Filters by period. It always goes first: it reduces the set before working and can use an index | WHERE |
$group |
Groups by bike and accumulates | GROUP BY + SUM/AVG/COUNT |
$sort |
Orders by kilometers | ORDER BY |
$limit |
Keeps five | LIMIT |
$project |
Shapes the output | The SELECT list |
-- The PostgreSQL equivalent, if this data were there
SELECT b.plate, m.name AS model,
ROUND(SUM(t.distance_m)/1000.0, 1) AS km, COUNT(*) AS trips,
ROUND(AVG(t.distance_m)/1000.0, 2) AS avg_km
FROM telemetry t JOIN bicycles b USING (bicycle_id)
JOIN bike_models m USING (model_id)
WHERE t.start_ts >= DATE '2026-06-01' AND t.start_ts < DATE '2026-07-01'
GROUP BY b.plate, m.name ORDER BY km DESC LIMIT 5;Almost the same query. The real difference: the SQL needs two JOINs to reach the model, and the pipeline needs none because the model is copied inside the document. That is the central trade-off of the document model, in one sentence: duplication in exchange for local access.
Q6 — Heat map of routes by district
db.trips_telemetry.aggregate([
{ $match: { start_ts: { $gte: ISODate("2026-06-01"), $lt: ISODate("2026-07-01") } } },
{ $unwind: "$points" }, // 1 doc → 168 docs
{ $project: {
cell: { // grid of ~0.001° ≈ 110 m
lon: { $round: [ { $arrayElemAt: ["$points.p", 0] }, 3 ] },
lat: { $round: [ { $arrayElemAt: ["$points.p", 1] }, 3 ] }
},
hour: { $hour: { date: "$start_ts", timezone: "Europe/Madrid" } }
} },
{ $group: { _id: { cell: "$cell", peak: { $in: ["$hour", [7,8,9,17,18,19]] } },
passes: { $sum: 1 } } },
{ $match: { passes: { $gte: 500 } } },
{ $sort: { passes: -1 } }, { $limit: 8 }
]);[
{ _id: { cell: { lon: -3.198, lat: 40.116 }, peak: true }, passes: 41822 },
{ _id: { cell: { lon: -3.197, lat: 40.117 }, peak: true }, passes: 39140 },
{ _id: { cell: { lon: -3.198, lat: 40.116 }, peak: false }, passes: 22410 },
...
]The $unwind is the dangerous stage and you have to understand why. It turns each trip document into 168 documents, one per point. Over 45,000 trips in a month that is 7.5 million intermediate documents. That is exactly the reason for the two precautions the pipeline carries: the $match goes before the $unwind (if it came afterwards, the 1.6 million trips of the year would be unwound) and the $project reduces each point to two rounded numbers before grouping.
It is also the answer to why Q6 tolerates 10 seconds and runs 30 times a day, not 30,000. A pipeline with a $unwind over millions of documents is not an application query: it is a report.
Q8 — Incidents by bicycle model
db.incidents.aggregate([
{ $match: { opened_at: { $gte: new Date(Date.now() - 90*24*3600*1000) } } },
{ $group: {
_id: { model: "$bicycle.model", type: "$type" },
n: { $sum: 1 },
avg_severity: { $avg: "$severity" },
bikes: { $addToSet: "$bicycle.id" } } },
{ $group: {
_id: "$_id.model",
total: { $sum: "$n" },
by_type: { $push: { type: "$_id.type", n: "$n",
severity: { $round: ["$avg_severity", 1] } } },
affected_bikes: { $sum: { $size: "$bikes" } } } },
{ $sort: { total: -1 } }
]);[
{ _id: 'Norvent Urban2', total: 412, affected_bikes: 188,
by_type: [ { type: 'brakes', n: 201, severity: 3.1 },
{ type: 'wheel', n: 142, severity: 2.4 },
{ type: 'other', n: 69, severity: 1.8 } ] },
{ _id: 'Ciclmar E-Vall', total: 287, affected_bikes: 121,
by_type: [ { type: 'battery', n: 158, severity: 3.9 },
{ type: 'electronics', n: 74, severity: 3.2 },
{ type: 'brakes', n: 55, severity: 2.7 } ] }
]The double $group is the pattern to learn from here. The first groups by (model, type); the second groups again by model and uses $push to put the results of the first into a nested array. The outcome is a hierarchical structure that in SQL would require either two queries or one with window functions and a manual pivot with FILTER like the one you did in 07-04. Producing hierarchies is where the aggregation pipeline clearly beats SQL.
And notice what makes the query possible: bicycle.model is copied into every incident. Without that duplication, grouping by model would require going to PostgreSQL, and there is no way to do that from a pipeline.
- Geospatial queries: what the relational side did worse
Q2 —"stations less than 500 m away"— is the query that came out worst in 08-01. With latitude and longitude as NUMERIC, the only way out without extensions is a Haversine formula in the SELECT, which cannot use any index: it forces you to compute the distance to all 60 stations and filter afterwards.
In MongoDB it is one index line and one query.
db.stations.find(
{ location: { $near: {
$geometry: { type: "Point", coordinates: [-3.1995, 40.1158] },
$maxDistance: 500 } },
"capacity.covered": true },
{ name: 1, address: 1, "capacity.docks": 1 }
);[
{ _id: 12, name: 'Station 12 · North Wharf', address: '14 Wharf Promenade',
capacity: { docks: 24 } },
{ _id: 41, name: 'Station 41 · Fish Market', address: '3 Fish Market Avenue',
capacity: { docks: 20 } }
]Three things come free in that query: the results arrive sorted by distance without asking, the $maxDistance is in meters over the sphere (not in degrees), and the additional capacity.covered filter combines with the geospatial one without ceremony.
For Q3, "stations inside the district polygon", the operator is a different one:
db.stations.find({ location: { $geoWithin: { $geometry: {
type: "Polygon",
coordinates: [[ [-3.210,40.108], [-3.190,40.108],
[-3.190,40.122], [-3.210,40.122], [-3.210,40.108] ]]
}}}}).count();$near sorts by proximity and needs an index; $geoWithin does not sort and can work without one, although it does much better with it. And the classic mistake, which deserves bold type because everybody makes it the first time: GeoJSON is [longitude, latitude], in that order. The other way round, Vallmar shows up in the Indian Ocean and the queries return zero results without raising any error.
- Indexes,
explain() and expiry with TTL
explain() and expiry with TTLdb.stations.createIndex({ "district.id": 1, "capacity.covered": 1 }); // Q3
db.trips_telemetry.createIndex({ "bicycle.id": 1, start_ts: -1 }); // Q5
db.trips_telemetry.createIndex({ start_ts: 1 }); // Q6
db.incidents.createIndex({ status: 1, type: 1, opened_at: -1 }); // Q7
db.incidents.createIndex({ "bicycle.model": 1, opened_at: -1 }); // Q8
db.incidents.createIndex({ workshop_order_id: 1 }, { unique: true }); // integrityThe order of the fields in a composite index follows the same rule as in PostgreSQL (06-03): equality first, range or ordering at the end. { status: 1, type: 1, opened_at: -1 } serves Q7 completely, and also "all open incidents" —a prefix of the index—, but not "all brakes incidents", because type is not a prefix. It is identical to what happened with composite B-tree indexes.
Checking with explain():
db.incidents.find({ status: "open", type: "brakes" })
.sort({ opened_at: -1 }).explain("executionStats").executionStats;{
executionSuccess: true,
nReturned: 23,
executionTimeMillis: 1,
totalKeysExamined: 23,
totalDocsExamined: 23,
executionStages: { stage: 'FETCH', ... inputStage: { stage: 'IXSCAN',
indexName: 'status_1_type_1_opened_at_-1',
keyPattern: { status: 1, type: 1, opened_at: -1 } } }
}How to read it. totalKeysExamined == totalDocsExamined == nReturned is the perfect result: the index located exactly the 23 rows needed, with no discards. And there is no SORT stage, because the index already delivers the requested order. The reading is the same one you did with EXPLAIN ANALYZE: if totalDocsExamined were 40,000 to return 23, you would have the equivalent of a high Rows Removed by Filter.
TTL: expiring old telemetry
The city council keeps the telemetry for 180 days; after that only the aggregates are of interest. In PostgreSQL that would be a monthly DELETE over 45 million rows, with its corresponding VACUUM and its maintenance window, or else range partitioning.
In MongoDB it is an index:
db.trips_telemetry.createIndex(
{ start_ts: 1 },
{ expireAfterSeconds: 15552000, name: "ttl_180_days" } // 180 days
);An internal process walks the index every 60 seconds and deletes what has expired. Three warnings, because TTL surprises anyone who does not know them:
- The deletion is not punctual. A document can survive up to a minute —or more, under load— after its expiry. It is not good enough for legal requirements of "exact" deletion.
- The field must be a date. If
start_tswere a string, the index works but deletes nothing, silently. - In a replica set only the primary deletes, and the replicas receive the deletion through replication. That is the correct behavior, but it means a lagging secondary holds documents that "no longer exist".
Before the TTL deletes anything, a monthly process consolidates what does have to be kept:
db.trips_telemetry.aggregate([
{ $match: { start_ts: { $gte: ISODate("2026-01-01"), $lt: ISODate("2026-02-01") } } },
{ $group: { _id: { bike: "$bicycle.id", month: "2026-01" },
km: { $sum: { $divide: ["$distance_m", 1000] } }, trips: { $sum: 1 } } },
{ $merge: { into: "telemetry_monthly", on: "_id", whenMatched: "replace" } }
]);$merge is the document-world equivalent of a materialized view from 05-04: the detail expires, the summary remains.
- What has been lost and how it is mitigated
A case study that only tells you the advantages is advertising. These are the four real losses, with their mitigation and with what the mitigation does not achieve.
Loss 1 — There is no foreign key toward bicycles
In 08-01, trips.bicycle_id REFERENCES bicycles guaranteed that a trip of a non-existent bike cannot exist. Here, trips_telemetry.bicycle.id = 417 is a number. If somebody deletes bike 417 from PostgreSQL, MongoDB does not find out and keeps orphan telemetry forever.
Mitigation: (a) the $jsonSchema forces the field to exist and to have the right format, which avoids typing errors but not referential ones; (b) ON DELETE RESTRICT in PostgreSQL means bikes are never deleted, only marked status = 'withdrawn' — and that decision, taken in 08-01 for accounting reasons, turns out to be the one that also protects coherence between engines; (c) a nightly reconciliation that lists the distinct bicycle.id values in MongoDB and checks that they all exist in PostgreSQL.
// Step 1: pull the ids MongoDB thinks it knows
db.trips_telemetry.distinct("bicycle.id");
// → [1, 2, 3, ... 900, 947]-- Step 2: contrast them against the truth
SELECT unnest(ARRAY[1,2,3,...,900,947]) AS id
EXCEPT
SELECT bicycle_id FROM bicycles;That 947 is an orphan: telemetry of a bicycle that does not exist. The reconciliation does not prevent it; it detects it. That is a difference in nature, not in degree: we have gone from a guarantee to an alarm.
Loss 2 — There is no JOIN, and $lookup is not the solution
$lookup joins two collections in the same MongoDB database. It cannot join with PostgreSQL. Full stop. Any report that crosses telemetry with billed amounts needs:
| Option | How | When to use it |
|---|---|---|
| Join in the application | Query both engines and combine in memory | Few records, one-off query |
| Duplicate the needed field | Copy model and plate into the document (what we did) |
Stable, heavily queried field |
| Analytical store | Dump both into a third system for reporting | Reports that genuinely cross data |
And even if the collections were in the same MongoDB, $lookup is not an equivalent JOIN: it runs as a loop over the foreign collection, it does not exploit statistics and there is no planner to reorder anything. It is a tool for enriching already-filtered results, not for querying two large collections at once. The practical rule: if your pipeline starts with a $lookup over millions of documents, the model is wrong.
Loss 3 — Coherence is left in the application's hands
Nothing prevents bicycle.model from saying "Norvent Urban2" when PostgreSQL says something else. The mitigation is procedural, not engine-based:
- Idempotent writes: the
_idis thetrip_id, so rewriting the same document twice produces the same result. That is worth gold when there are retries. - A single writer per collection: only the telemetry service writes to
trips_telemetry. No three different services touching the same collection "because it is faster". - Scheduled reconciliation with a discrepancy report, like the one above.
schema_vin every document: when the shape of the document changes, the old ones are still readable and you know which ones to migrate.
Loss 4 — Rich constraints do not exist
There is no EXCLUDE USING gist, there is no partial unique index with an arbitrary predicate, there is no cross-column CHECK expressed with the naturalness of SQL. $jsonSchema validates shape and ranges; it does not validate relationships between documents or temporal conditions.
Mitigation: put in MongoDB only data where those constraints are not needed. Which, if you look closely, is precisely the criterion from section 1 read backwards. A coherent design is one that does not need the guarantees its engine does not give.
- The honest comparison: PostgreSQL with
jsonb and PostGIS
jsonb and PostGISThe uncomfortable question that had to be asked: everything in this lesson can be done in PostgreSQL. jsonb stores documents with GIN indexes; PostGIS does geospatial work better than MongoDB; range partitioning expires data better than TTL. Why not stay on a single engine?
| Criterion | PostgreSQL + jsonb + PostGIS |
MongoDB | Who wins in VallBici |
|---|---|---|---|
| Heterogeneous station profile | jsonb + GIN index: works well |
Native | Tie |
| Geospatial queries | PostGIS is more powerful (routing, topology, projections) | 2dsphere covers the basics |
PostgreSQL |
| Telemetry: write volume | High per-row cost (WAL, MVCC, autovacuum) | Lower cost, $push over a document |
MongoDB |
| Expiring old data | Partitioning + DETACH PARTITION: extremely efficient |
TTL: convenient, less control | PostgreSQL |
| Adding an incident type | jsonb: no migration |
No migration | Tie |
| Crossing telemetry with charges | A real JOIN, a single engine |
Impossible without going out to the application | PostgreSQL |
| Horizontal write scaling | Requires work (Citus, partitioning, replicas) | Native sharding (03-01) | MongoDB |
| Pieces to operate | One | Two | PostgreSQL |
| People needed on the team | One skill set | Two skill sets | PostgreSQL |
Count the votes: PostgreSQL wins in more rows. And the honest conclusion is this:
If VallBici had 6 stations and 90 bicycles, the correct answer would be to stay in PostgreSQL with
jsonband PostGIS, no discussion.
What tips the balance in the real VallBici is two rows, not nine: 272 million GPS points a year and the council's forecast of doubling the fleet in three years. That write volume of data that needs neither transactions nor referential integrity is the case a document store exists for, and it is the only weighty reason.
The signals that would say "go back to PostgreSQL, this was not worth it":
- The telemetry turns out to be queried crossed with the charges constantly.
- The volume plateaus at a level a well-partitioned PostgreSQL can take.
- The team cannot keep real competence in both engines.
- Complex geospatial requirements appear (routing, isochrones) that PostGIS would do and
2dspherewould not.
None of those signals is shameful. Undoing an architecture decision when the data changes is professional competence, not failure.
Common Mistakes and Tips
Mistake 1: migrating everything. The complete case in this lesson is three collections. Nobody touched billing. A project that starts with "let's move the database to MongoDB" instead of "these three datasets fit better in documents" has already made the decision before analyzing it.
Mistake 2: one document per GPS point. The calculation in section 4 is the argument: 269 million documents against 1.6 million. When data arrives as a time series associated with something, the bucket is almost always the answer.
Mistake 3: a bucket with no ceiling. The 16 MB limit gives no warning until you hit it, and then the write fails in production. $slice in the $push and a window flag.
Mistake 4: [latitude, longitude]. GeoJSON is [lon, lat]. It raises no error: it simply returns zero results and makes you lose an afternoon.
Mistake 5: $unwind before $match. It multiplies the documents and filters afterwards. It can turn an 8-second pipeline into an 8-minute one.
Mistake 6: creating the collection without a validator "because the application already validates it". It is the same sentence that in 08-01 justified not adding a CHECK, and it ends the same way. In the document world it is worse, because the damage accumulates silently for months.
Tip 1: write the query table before the first document. The nine rows of section 2 determined the three collections, the duplicated fields and the six indexes.
Tip 2: short field names only inside large arrays. In points it is justified by the 8 GB a year. Putting n instead of name in stations is gratuitous illegibility: they are 60 documents.
Tip 3: a meaningful _id whenever you can. Using PostgreSQL's trip_id as the _id gives you idempotence for free, saves an index and makes the correspondence between engines obvious.
Tip 4: read explain() with the same eyes as EXPLAIN ANALYZE. totalDocsExamined against nReturned is MongoDB's Rows Removed by Filter, and an in-memory SORT stage is the warning that an index is missing.
Tip 5: write the reconciliation process today. When there are two engines, the question is not whether they will diverge, but when and how long it will take you to find out.
Exercises
Exercise 1 — Station ratings
The app is going to allow rating a station from 1 to 5 with an optional comment and up to three tags (dirty, poorly_lit, stiff_docks…). About 200 ratings a day are expected. The station profile must show the average score and the three most recent ratings.
- Decide whether the ratings are embedded in
stationsor go in a collection of their own, with the criteria from 03-03. - Write the resulting document and the necessary indexes.
- Write the operation that records a new rating and keeps what is shown on the profile up to date.
Exercise 2 — Detecting anomalous trips
Operations wants to detect suspicious trips: average speed above 35 km/h (the bike travelled in a vehicle), more than 20 minutes at speed 0 in the middle of the trip, or an electric bicycle losing more than 40% of its battery in less than 15 minutes.
Write an aggregation pipeline that returns the trips of the last day meeting any of the three conditions, with the condition they triggered and the bicycle's data.
Exercise 3 — The field that went stale
An audit discovers that 41,000 documents in trips_telemetry have bicycle.plate in the old format (0417 instead of VB-0417), because for two weeks the gateway wrote the field wrongly.
- Write the query that locates them.
- Write the update that fixes them without pulling the documents into the application.
- Explain why this problem could not have happened in the relational schema of 08-01, and what MongoDB mechanism could have prevented it.
Solutions
Solution 1
1. Its own collection with an embedded subset. The criteria from 03-03 give a clear answer: 200 ratings a day × 60 stations over years is a "one to very many" with no ceiling, and an unbounded array inside a document read 60,000 times a day is the unbounded-array anti-pattern. But the profile needs the three most recent, and a second query on the app's hottest path is a real cost.
The answer is the subset pattern: a ratings collection with everything, and in stations a summary with the three most recent and the aggregates.
2. The design:
// Full collection
{ _id: ObjectId("..."), station_id: 12, subscription_id: 10233, score: 4,
comment: "Dock 7 very stiff", tags: ["stiff_docks"],
ts: ISODate("2026-06-14T18:22:00Z") }
// In the station document
{ _id: 12, /* ... */
ratings_summary: {
n: 1284, avg: 4.12, sum: 5290,
top_tags: [ { t: "stiff_docks", n: 91 }, { t: "dirty", n: 44 } ],
latest: [ { score: 4, comment: "Dock 7 very stiff",
ts: ISODate("2026-06-14T18:22:00Z") } ] // maximum 3
} }
db.ratings.createIndex({ station_id: 1, ts: -1 });
db.ratings.createIndex({ subscription_id: 1, station_id: 1, ts: -1 });sum is stored in addition to avg so the average can be recomputed incrementally without walking 1,284 documents.
3. The write, in two operations:
db.ratings.insertOne({ station_id: 12, subscription_id: 10233, score: 4,
comment: "Dock 7 very stiff", tags: ["stiff_docks"], ts: new Date() });
db.stations.updateOne(
{ _id: 12 },
[ { $set: {
"ratings_summary.n": { $add: [ { $ifNull: ["$ratings_summary.n", 0] }, 1 ] },
"ratings_summary.sum": { $add: [ { $ifNull: ["$ratings_summary.sum", 0] }, 4 ] },
"ratings_summary.latest": {
$slice: [ { $concatArrays: [
[ { score: 4, comment: "Dock 7 very stiff", ts: "$$NOW" } ],
{ $ifNull: ["$ratings_summary.latest", []] } ] }, 3 ] }
} },
{ $set: { "ratings_summary.avg": { $round: [ { $divide: [
"$ratings_summary.sum", "$ratings_summary.n" ] }, 2 ] } } } ]
);Two pipeline stages because the second needs the values set by the first. $concatArrays + $slice: 3 keeps the three most recent at the front and discards the rest. They are not atomic with respect to each other: if the second fails, there is a rating recorded that does not appear in the summary. It is mitigated with a nightly recomputation of the summary, which is the known price of every computed field in the document world.
Solution 2
db.trips_telemetry.aggregate([
{ $match: { start_ts: { $gte: new Date(Date.now() - 24*3600*1000) },
end_ts: { $exists: true } } },
{ $set: {
duration_s: { $divide: [ { $subtract: ["$end_ts", "$start_ts"] }, 1000 ] },
stopped_points: { $size: { $filter: { input: "$points", cond: { $eq: ["$$this.v", 0] } } } },
battery_drop: { $subtract: ["$battery.start_pct", "$battery.end_pct"] } } },
{ $set: {
avg_speed_kmh: { $cond: [ { $gt: ["$duration_s", 0] },
{ $multiply: [ { $divide: ["$distance_m", "$duration_s"] }, 3.6 ] }, 0 ] },
stopped_minutes: { $divide: [ { $multiply: ["$stopped_points", 5] }, 60 ] } } },
{ $match: { $or: [
{ avg_speed_kmh: { $gt: 35 } },
{ stopped_minutes: { $gt: 20 } },
{ $and: [ { "bicycle.type": "electric" },
{ battery_drop: { $gt: 40 } },
{ duration_s: { $lt: 900 } } ] } ] } },
{ $project: {
plate: "$bicycle.plate", model: "$bicycle.model",
km: { $round: [ { $divide: ["$distance_m", 1000] }, 2 ] },
avg_speed_kmh: { $round: ["$avg_speed_kmh", 1] },
stopped_minutes: { $round: ["$stopped_minutes", 0] },
battery_drop: 1,
reason: { $switch: { branches: [
{ case: { $gt: ["$avg_speed_kmh", 35] }, then: "impossible_speed" },
{ case: { $gt: ["$stopped_minutes", 20] }, then: "prolonged_stop" } ],
default: "abnormal_battery_drain" } } } },
{ $sort: { avg_speed_kmh: -1 } }
]);[
{ _id: 891044, plate: 'VB-0233', model: 'Norvent Urban2', km: 18.4,
avg_speed_kmh: 47.2, stopped_minutes: 1, reason: 'impossible_speed' },
{ _id: 890877, plate: 'VB-0512', model: 'Ciclmar E-Vall', km: 2.1,
avg_speed_kmh: 5.4, stopped_minutes: 34, reason: 'prolonged_stop' },
{ _id: 890912, plate: 'VB-0733', model: 'Ciclmar E-Vall', km: 3.8,
avg_speed_kmh: 12.9, stopped_minutes: 2, battery_drop: 46,
reason: 'abnormal_battery_drain' }
]Three points worth highlighting: the initial $match goes first and uses the index over start_ts; the $filter counts stopped points without $unwind, which is the correct way to operate over arrays when you do not need to unwind them; and $switch labels the reason, respecting the same priority order as the $or.
Solution 3
1. Locating them:
2. Fixing them on the server:
db.trips_telemetry.updateMany(
{ "bicycle.plate": /^\d{4}$/ },
[ { $set: { "bicycle.plate": { $concat: ["VB-", "$bicycle.plate"] } } } ]
);The update pipeline lets you build the new value out of the old one inside the engine. Without it, you would have to read 41,000 documents, transform them in the application and rewrite them: a few minutes of network and memory for something that here takes two seconds. Notice that the updateMany filter is /^\d{4}$/ and not the $not from the query: only those with the known old format have to be fixed, not anything that fails to match.
3. Why it would not have happened in the relational world, and what would have prevented it here. In 08-01 the plate is not copied anywhere: it lives only in bicycles.plate, with its CHAR(7) UNIQUE, and trips reaches it by foreign key. A value that exists only once cannot be written wrongly in the copy, because there is no copy. The duplication that gives us performance here in Q5 and Q8 is exactly what made the error possible.
What would have prevented it in MongoDB: a $jsonSchema on trips_telemetry with pattern: "^VB-[0-9]{4}$" over bicycle.plate, just like the one we did put on incidents. The incidents collection was protected and the telemetry one was not, and the error appeared exactly where the validator was missing. That is no coincidence: it is the rule.
Conclusion
You have taken three concrete pieces of the VallBici system —telemetry, station profile and incidents— and solved them in MongoDB with the method from 03-03: the nine queries first, the documents afterwards. The result is three collections with defensible decisions: stations with extended references and free-form subdocuments, trips_telemetry with the bucket pattern, a 900-point ceiling and computed fields, and incidents with a $jsonSchema that validates what is common and leaves what is specific free.
And you have seen what each advantage costs. bicycle.model copied inside every document is what makes it possible to group by model without leaving the engine; it is also what produced the 41,000 badly written plates in exercise 3. The bucket pattern reduces 269 million documents to 1.6 million; it also forces you to watch a 16 MB limit that did not exist before. TTL expires the telemetry with an index; it also deletes when it feels like it, not when you say so. In document modeling there are no free decisions: there are decisions whose price you know and decisions whose price you will discover in production.
The comparison in section 11 is the part worth remembering a year from now. PostgreSQL with jsonb and PostGIS wins on more criteria than it loses, and for a small VallBici it would be the correct answer with no qualifications. What justifies the second engine is not the elegance of the document model: it is 272 million annual writes that need neither transactions nor referential integrity. When somebody proposes adding a database to a system, that is the question to ask — what number justifies this?— and if there is no number, there is no reason.
One open problem remains, and it is the hardest of the three. From now on, bicycle 417 has data in two places: its plate, its model and its status are in PostgreSQL, and they are also copied inside thousands of MongoDB documents. Station 12 has eleven columns in PostgreSQL and a thirty-field profile in MongoDB. Nobody has said which one wins. When the two disagree —and they will— which version is the true one? With what delay does a change propagate? What happens if the synchronization process goes down on a Tuesday night? And we still have to bring Redis into the equation, which will hold real-time availability, and Elasticsearch, which will serve the search for stations by name and address.
That is polyglot persistence, and it is lesson 08-03: not how each engine is designed —that is already done— but how you decide who is the source of the truth for each piece of data, how they are synchronized, what keeps working when one of them goes down and when it is worth dismantling this whole architecture and going back to a single database.
Database Fundamentals
Module 1: Introduction to Databases
- Basic Database Concepts
- Types of Databases
- History and Evolution of Databases
- Database Management Systems and Architecture
Module 2: Relational Databases
- The Relational Model
- The SQL Language
- Basic SQL Operations
- Multi-Table Queries: JOINs and Subqueries
- Data Aggregation and Grouping
- Referential Integrity
Module 3: Non-Relational Databases
- Introduction to NoSQL
- Types of NoSQL Databases
- Data Modeling in NoSQL
- Comparing Relational and Non-Relational Databases
Module 4: Schema Design
- Schema Design Principles
- Entity-Relationship (ER) Diagrams
- Transforming ER Diagrams into Relational Schemas
- Data Types and Constraints
Module 5: Normalization
Module 6: Transactions, Performance, and Security
- Transactions and ACID Properties
- Concurrency and Isolation Levels
- Indexes and Query Optimization
- Security, Permissions, and Backups
Module 7: Practical Exercises
- SQL Exercises
- Schema Design Exercises
- Normalization Exercises
- Advanced Query and Transaction Exercises
Module 8: Case Studies
- Case Study: Relational Database
- Case Study: Non-Relational Database
- Case Study: Polyglot Persistence
