Java 301 · Session 5 — Databases

NoSQL, MongoDB & Spring Data

Why relational tables stop being the right tool at scale, what a document database does instead, and how to talk to MongoDB — from the raw shell all the way up to Spring Data repositories in a QEats-style service.

Updated August 2026 · Read ~18 min


Choose your route

Study the same NoSQL, MongoDB & Spring Data explanation with a different purpose: build and verify it in Learn deeply, or retrieve and defend it in Prepare for interviews.

Learning goal

Shared core

The chapter below remains the canonical explanation for both goals. Keep its model, examples, and trade-offs shared; only the deliberate practice changes with the route.

Learn deeply: build evidence

Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.

Prepare for interviews: explain the trade-off

Without notes, explain the boundary this chapter protects, one concrete implementation decision, and the cost of a tempting shortcut. Then answer one follow-up using the chapter’s own example.

// the big idea

SQL forces you to design a rigid schema up front and stitch data back together with JOINs; MongoDB stores each entity as one self-contained document so you read it in a single lookup, change its shape without a migration, and spread it across machines by default. You trade guaranteed cross-table consistency for flexibility, read speed, and horizontal scale.

Section 1

Notebook Summary

Hand-copy this. Terse rules and the boxed markers; the full reasoning is in Section 2.

Why not SQL (at scale)?

What is NoSQL?

4 families →

doodle: 4 boxes labelled KV / COL / DOC / GRAPH; circle DOC.

SQL ↔ Mongo term map

SQL MongoDB
Database Database
Table Collection
Row Document (BSON/JSON)
Column Field
JOIN Embed / $lookup
Primary key _id

Why MongoDB

doodle: one box "product { name, price, reviews[…] }" — reviews nested inside.

CRUD verbs

Op Mongo
Create insertOne / insertMany
Read find / findOne
Update updateOne + $set
Delete deleteOne / deleteMany

Spring Data MongoDB

Markers to box


Section 2

Detailed Study Notes

You are building the product catalog for QEats — an app that lists restaurants and dishes. You start where every backend course starts: a relational database. A restaurants table, a menu_items table, an attributes table, a reviews table, all wired together with foreign keys. Rendering one restaurant page means joining five tables. Then the product team says: "some restaurants have opening hours per weekday, some have a single flat time, and a few now have delivery zones." Your neat schema does not have a column for that, and adding one means an ALTER TABLE on a table with millions of rows. This is the moment the relational model starts fighting you — and the reason document databases exist.

2.1 Where relational databases start to hurt

A relational database (SQL) stores data as rows in fixed-column tables and rebuilds related data on demand by joining tables together. That model is excellent for well-understood, highly structured data with strong consistency needs — banking, inventory, anything where a row must never be half-written. It stops being comfortable in three specific ways, and the lesson named all three.

Scalability

Hard to scale out

One entity is spread across many related tables. Splitting that across machines (horizontal scaling) means your JOINs now cross the network — very difficult to do well.

Flexibility

Rigid schema

The schema is fixed and defined up front. You spend hours designing it before writing code, and changing it later — when requirements shift — is painful.

Performance

Costly JOINs

Complex joins are CPU-intensive. You add indexes at many levels to stay fast, but each index costs write speed and storage.

Analogy · recall

A relational database is a filing cabinet with fixed dividers. Every document must be torn apart and filed by type — names here, addresses there, phone numbers in a third drawer. To read one person back you open every drawer and reassemble them (that reassembly is the JOIN). Add a new field for everyone and you must re-cut every divider.

The Amazon-products test [+]

The session ran a thought experiment: look at two very different Amazon listings — a running shoe and a smartphone. The shoe has size, color, material. The phone has RAM, storage, screen size, battery. Will one fixed RDBMS schema hold both? Either you create a wide table full of columns that are NULL for most products, or you build an "attributes" side-table and JOIN it back for every read. Both are awkward. This is the classic case where each product is naturally one flexible document with whatever fields it needs — and nothing more.

💡 tip · interview

"SQL vs NoSQL" is rarely about which is better — it is about fit. Structured data with strict consistency (payments) → relational. Varied, evolving, high-volume, read-heavy data (product catalogs, activity feeds, user profiles) → document. Saying "it depends on the access pattern" is the answer interviewers want.

2.2 What NoSQL actually means

NoSQL is a family of non-relational databases that drop the fixed-table, JOIN-based model in favor of flexible, self-contained records that are easy to distribute across many machines. "NoSQL" is best read as "not only SQL" — not "SQL is forbidden," but "the relational model is no longer the only option." The shared traits the lesson emphasized:

⭐ trick · why no-JOINs is the point

Dropping JOINs looks like a limitation, but it is the enabling feature. A JOIN needs all the related rows reachable together; the moment you split a table across machines, JOINs get slow or impossible. Because a document holds its related data inside itself, any single machine can answer a read alone — which is exactly what lets Mongo shard (spread data across servers) cleanly.

The four families

NoSQL is an umbrella. The lesson grouped it into four types by how a record is shaped. MongoDB lives in the document family.

Key–Value key → blob Redis Memcached Column wide sparse rows Cassandra HBase · BigTable Document JSON-shaped docs MongoDB CouchDB · Riak Graph nodes + edges Neo4j 4 ways to shape a record →
Fig 2.2 — the NoSQL families; this lesson lives in the document column.
Family Record shape Good for Examples
Key–Value key → opaque value caches, sessions Redis, Memcached
Column wide, sparse rows huge write-heavy tables Cassandra, HBase, BigTable
Document JSON-like documents catalogs, profiles, content MongoDB, CouchDB, Riak
Graph nodes + relationships networks, recommendations Neo4j

2.3 The mental model shift: tables → documents

MongoDB stores data as documents — JSON-shaped bundles of key–value pairs — grouped into collections, where a collection plays the role a table played in SQL. The single most useful thing you can carry out of this lesson is the vocabulary map between the two worlds, because every Mongo concept has a relational cousin.

Relational (SQL) MongoDB What it is
Database Database Top-level container
Table Collection A group of similar records
Row / record Document One entity, stored whole
Column Field One key inside a document
Primary key _id Unique id, auto-added by Mongo
JOIN Embed / $lookup Relate data — usually by nesting it

Two ideas deserve their own paragraph. First, a document is just key–value pairs — the same JSON you already send over HTTP. Mongo actually stores it as BSON (Binary JSON), a compact binary form that adds types like dates and 64-bit ints, but you read and write it as JSON. Second, a collection holds a set of documents and behaves like a table — except it does not impose fixed columns, so two documents in the same collection can legitimately have different fields.

SQL — 3 tables, joined per read products id · name · price reviews id · product_id · text attributes product_id · key · val JOIN MongoDB — one embedded document { _id: 1, name: "Shoe", price: 2499, attrs: { size: 9, color: "red" }, reviews: [ { user: "a", stars: 5 }, { user: "b", stars: 4 } ] } → one read, no JOIN
Fig 2.3 — three joined tables collapse into one self-contained document.
⚠ pitfall · schema-less ≠ no design

"Schema-less" tempts beginners into skipping data modeling entirely. But if you embed the same data in many documents (a restaurant's name copied into every order), you now own the job of keeping every copy in sync — the database will not do it for you. You still design; you just design around how you read the data instead of around normalization.

2.4 Why MongoDB specifically

MongoDB is the leading document database: it stores each entity as one JSON-shaped document, lets related data live nested inside that document, and distributes collections across machines by default. Restating the two building blocks the session drilled:

The practical payoff: for a read-heavy catalog, you fetch a whole product — attributes, reviews, images — in a single query against a single collection. No join planner, no five-table read path, and when the shoe needs a material field the phone will never have, you simply add it to the shoe documents.

⭐ trick · model by access pattern

In SQL you model by entities and normalize. In Mongo you model by access pattern: ask "what do I read together, in one screen?" and embed exactly that into one document. Data read together should be stored together. This one reframing is what makes Mongo fast and what most SQL-trained engineers get wrong first.

2.5 CRUD — talking to Mongo directly

CRUD is the four things you ever do to stored data — Create, Read, Update, Delete — and Mongo gives each a method you call on a collection. Before any framework hides it, it is worth seeing the raw shell commands, because Spring Data is only a thin wrapper over exactly these.

Create · insert→ Read · find→ Update · $set→ Delete · deleteOne
mongosh — CRUD on the products collection
// CREATE — insert one document (Mongo adds _id automatically)
db.products.insertOne({ name: "Shoe", price: 2499, color: "red" })

// CREATE many at once
db.products.insertMany([
  { name: "Phone", price: 10999, ram: 6 },
  { name: "Cap",   price: 399 }
])

// READ — filter is itself a document; {} matches everything
db.products.find({ color: "red" })
db.products.findOne({ name: "Phone" })

// READ with an operator — price greater than 500
db.products.find({ price: { $gt: 500 } })

// UPDATE — $set changes only the named fields
db.products.updateOne(
  { name: "Shoe" },
  { $set: { price: 1999 } }
)

// DELETE
db.products.deleteOne({ name: "Cap" })

Two patterns run through all of it. A query is just a document describing what to match — { color: "red" } means "field color equals red." And query operators start with $: $gt greater-than, $lt less-than, $in in-a-list, $set set-fields. Once you see that filters and updates are themselves documents, the whole query language stops feeling like new syntax.

Analogy · recall

A Mongo query is a "wanted" poster. You hand the collection a partial description — "red, priced over 500" — and it returns every document that fits the description. The more fields on the poster, the narrower the match.

⚠ pitfall · update without $set

If you write updateOne({name:"Shoe"}, {price:1999}) — the second argument without $set — Mongo treats it as a full replacement and the resulting document becomes just {price:1999}, silently dropping name and every other field. Always reach for an update operator ($set, $inc, $push) unless you truly mean to replace the whole document.

2.6 Spring Data MongoDB — the Java layer

Spring Data MongoDB lets you work with documents as plain Java objects: you annotate a class, extend a repository interface, and Spring generates the CRUD and query code for you. This is how QEats actually talks to Mongo — you almost never write shell commands in the app. Three pieces do all the work.

@Document

Class = collection

Marks a POJO as a Mongo document. Each instance becomes one document in the named collection.

@Id

Field = _id

Marks the field that maps to Mongo's _id primary key.

MongoRepository

CRUD for free

Extend it and save, findById, findAll, delete already exist — no implementation.

Product.java — the entity
@Document(collection = "products")
public class Product {

    @Id
    private String id;          // maps to Mongo's _id

    private String name;
    private int price;
    private String color;

    // constructors, getters, setters …
}
ProductRepository.java — no body needed
public interface ProductRepository extends MongoRepository<Product, String> {

    // Derived query — Spring parses the METHOD NAME into a query.
    // This becomes: find({ color: ? })
    List<Product> findByColor(String color);

    // Becomes: find({ price: { $gt: ? } })
    List<Product> findByPriceGreaterThan(int price);
}
⭐ trick · the method name IS the query

Spring reads the repository method name and derives the query from it. findByColor → filter on color; findByPriceGreaterThan → {price: {$gt: …}}; findByColorAndPriceLessThan → both. You write a name, Spring writes the query. Get the field names exactly right — a typo like findBycolour fails at startup, not at call time (which is actually the good outcome: fail fast).

ProductService.java — using the repository
@Service
public class ProductService {

    private final ProductRepository repo;

    public ProductService(ProductRepository repo) {
        this.repo = repo;             // constructor injection
    }

    public Product add(Product p) {
        return repo.save(p);          // CREATE (and UPDATE if id exists)
    }

    public List<Product> redProducts() {
        return repo.findByColor("red");   // derived query
    }
}
Controller→ Service→ Repository→ MongoDB

The layered path a QEats request takes down to the document store.

💡 tip · save() is create-or-update

repo.save(p) inserts when the object has no id and updates when it does. There is no separate "insert vs update" call at this layer — Spring decides based on whether the _id already exists. This is the "upsert" behavior worth knowing for interviews.


Section 2 · practice

Interview & Practice Q&A

The follow-ups this material invites. Try to answer aloud before expanding.

Q1When would you pick SQL over MongoDB?

When the data is highly structured and relationships are central, and — most importantly — when you need strong, multi-record consistency and transactions, such as financial ledgers or inventory. Relational databases enforce a schema and guarantee that related rows stay consistent, which is exactly what those domains require. Reach for Mongo when data is varied, evolving, read-heavy, and needs to scale horizontally.

Q2Why does "no JOINs" actually help rather than hurt?

Because a JOIN needs all related rows reachable together, it becomes slow or impossible once a table is split across machines. Documents carry their related data inside themselves, so any single machine can satisfy a read on its own. That self-containment is precisely what lets MongoDB shard — spread a collection across many servers — without cross-machine coordination on every query.

Q3What is the difference between a document and a collection?

A document is a single record — a JSON-shaped bundle of key–value pairs representing one entity, stored physically as BSON. A collection is a group of documents, playing the role a table plays in SQL. The key difference from a table is that a collection imposes no fixed column list, so documents within it can have different fields.

Q4How do you model a one-to-many relationship in Mongo?

Two options. Embed the "many" as an array inside the "one" document (reviews nested inside a product) — best when you always read them together and the array stays bounded. Or reference by storing ids and resolving them separately (or with $lookup) — better when the "many" is large, shared, or changes independently. The choice follows the access pattern, not a rule.

Q5What does "schema-less" really mean, and what's the catch?

It means the database does not enforce a fixed shape, so you can add or omit fields per document without a migration. The catch is that all responsibility for consistency moves to you: if you embed duplicated data across documents, keeping every copy in sync is now your application's job. Schema-less removes the migration pain but not the need to design.

Q6In Spring Data, how does findByPriceGreaterThan become a query?

Spring Data parses the method name at startup: findBy starts a query, Price names the field, and GreaterThan is the operator, producing { price: { $gt: ? } } with the argument bound in. You write no body. A misspelled field name fails at application startup rather than at runtime — fast feedback that the query is wrong.

Q7What do @Document and @Id do?

@Document marks a Java class as a Mongo document and names the collection it maps to. @Id marks the field that maps to Mongo's primary key _id. Together they let Spring convert between your POJO and the stored BSON automatically.

Q8Why is forgetting $set on an update dangerous?

An update document without an operator is treated as a full replacement: the matched document is replaced by exactly what you passed, so every field you did not include is dropped. Using $set (or $inc, $push) tells Mongo to modify only the named fields and leave the rest intact.

Q9What is BSON and why not just store JSON?

BSON is Binary JSON — the on-disk format Mongo uses. It keeps JSON's shape but adds a length-prefixed binary encoding for fast traversal and richer types like dates, 64-bit integers, and binary blobs that plain JSON lacks. You still think and query in JSON; BSON is the efficient representation underneath.

Key Takeaways

document = JSON collection ≈ table no JOIN → shardable access-pattern modeling $gt $lt $in $set find({}) = all MongoRepository method name = query

Section 3

Microexercises

Reading proves you recognize it; these prove you can use it. Attempt each before opening the walkthrough.

3.1 Build-it — a Spring Data repository query

// your task

Given the Product entity below, add repository methods so QEats can:

  1. Find all products of a given color.
  2. Find all products cheaper than a given price.
  3. Find all products of a given color that are also under a given price.

Write no method bodies — use Spring Data derived query names only. Expected: calling findByColorAndPriceLessThan("red", 1000) returns every red product with price < 1000.

starter — Product.java
@Document(collection = "products")
public class Product {
    @Id private String id;
    private String name;
    private int price;
    private String color;
    // getters / setters …
}

public interface ProductRepository extends MongoRepository<Product, String> {
    // TODO: add the three derived queries
}
Show the full walkthrough

Step 1 — recall the grammar. A derived query name is findBy + <Field> + optional <Operator>, joined with And/Or. The field names must match the entity's fields exactly (case matters): color and price.

Step 2 — map each requirement to a name. "of a given color" is a plain equality → findByColor. "cheaper than" is the less-than operator → findByPriceLessThan. "color and under a price" chains both with And → findByColorAndPriceLessThan.

Step 3 — write the interface. Each method's parameters line up, in order, with the fields named in the method:

solution — ProductRepository.java
public interface ProductRepository extends MongoRepository<Product, String> {

    List<Product> findByColor(String color);

    List<Product> findByPriceLessThan(int price);

    List<Product> findByColorAndPriceLessThan(String color, int price);
}

Why — the method name is the query. You never implement these; Spring parses each name into a Mongo filter (findByPriceLessThan → {price:{$lt:?}}) and binds the arguments by position, so the whole data-access layer is declarative.

3.2 Diagnose-it — the vanishing fields

// your task

A teammate runs the shell commands below against a fresh products collection, then queries the document and is shocked. Predict the exact result of the final findOne, name the root cause, and fix the update so price changes while everything else survives.

mongosh — what did this do?
db.products.insertOne({ name: "Shoe", price: 2499, color: "red" })

db.products.updateOne(
  { name: "Shoe" },
  { price: 1999 }        // meant to just lower the price
)

db.products.findOne({ name: "Shoe" })   // what comes back?
Show the full walkthrough

Predict the failure first. The final findOne({ name: "Shoe" }) returns nothing — null. And a findOne({}) would show the document is now just { _id: …, price: 1999 }. The name and color fields are gone.

Root cause — replacement, not modification. The second argument to updateOne has no update operator. When the update document contains no $-operator, Mongo treats it as a full replacement: it swaps the matched document for exactly { price: 1999 } (keeping only _id). Since name no longer exists, the later filter on name: "Shoe" matches nothing.

The fix — wrap the change in $set. $set tells Mongo to modify only the named fields and leave the rest untouched:

fix — mongosh
db.products.updateOne(
  { name: "Shoe" },
  { $set: { price: 1999 } }
)

// now findOne({ name: "Shoe" }) returns:
// { _id: …, name: "Shoe", price: 1999, color: "red" }

Why — in Mongo an update is a document too, and an update document without an operator means "replace me with this." $set is the difference between editing a record and overwriting it — the single most common Mongo write bug.

findBy + Field + Op And chains conditions field names must match no $set = full replace $set edits in place