Learning MongoDB, one document at a time
I have spent most of my career putting rows into tables. Normalize, add a foreign key, add a join, let the database defend the truth. It works, it is boring in the best way, and it means that every time I touch MongoDB I catch myself designing a relational schema and then storing it in the wrong engine.
So I am doing the thing properly: a small playground repo, a series of posts, and an honest log of every place my SQL reflexes betray me. This is part one — the gentle intro. Spring Boot 4, Java 25, a Mongo container, and one domain small enough to hold in your head: customers, products, orders.
The setup takes about four minutes
The whole infrastructure story is a compose.yaml sitting next to the pom:
services:
mongodb:
image: 'mongo:latest'
environment:
- 'MONGO_INITDB_DATABASE=mydatabase'
- 'MONGO_INITDB_ROOT_USERNAME=root'
- 'MONGO_INITDB_ROOT_PASSWORD=secret'
ports:
- '27017:27017'
I never run docker compose up myself. spring-boot-docker-compose is on the runtime classpath, so booting the app starts the container and wires the connection details in — there is no spring.data.mongodb.uri in my properties at all. The only thing I set is this, so the container survives between restarts instead of being torn down every time DevTools reloads:
spring.docker.compose.lifecycle-management=start_only
That is genuinely the entire config file. A good reminder of how much "getting started" friction is accidental.
The one decision that actually matters: embed or reference
Everything else in MongoDB is syntax. This is the modelling. In SQL the question never comes up — you normalize, and joins pay the bill later. Here you choose, per relationship, whether a thing lives inside its parent document or next to it with an id.
The heuristic I have landed on: data that is read together and owned by one parent gets embedded; data that lives its own life gets referenced. My Order is the whole argument in one class:
@Document(collection = "orders")
@CompoundIndex(name = "customer_recent_idx", def = "{'customerId': 1, 'createdAt': -1}")
public class Order {
@Id private String id;
@Indexed private String customerId; // REFERENCE - a customer lives its own life
private Address shippingAddress; // EMBEDDED - a snapshot of where it shipped
private List<OrderLine> lines; // EMBEDDED - the heart of the aggregate
@Indexed private OrderStatus status;
private Instant createdAt;
}
Order lines are never queried without their order, they are bounded in number, and they are meaningless on their own. They go inside. Reading an order is then one query, zero joins, and the document that comes back is the aggregate — the DDD word and the storage layout finally agreeing with each other.
The customer does not go inside. Alice exists whether or not she has ever ordered anything, she is edited independently, and copying her into every order would mean chasing updates across the collection forever. So the order stores customerId and nothing more.
And then there is the part that made something click. An OrderLine holds both:
public class OrderLine {
private String productId; // reference: which product was it
private String productName; // snapshot: what it was called then
@Field(targetType = FieldType.DECIMAL128)
private BigDecimal unitPrice; // snapshot: what she actually paid
private int quantity;
}
My relational instinct screamed duplication. It is not duplication. An order is a historical record. If I bump the keyboard's price next week, this order must still show 129.99, because that is what the customer paid. The copy is not a stale cache of the product — it is a different fact that merely happens to look the same today. Denormalization here is not a performance hack, it is correctness. That reframing was worth the whole exercise on its own.
The gotcha that would have silently corrupted my money
Notice @Field(targetType = FieldType.DECIMAL128) on every BigDecimal. That annotation is not decoration.
In Spring Data MongoDB 5.0 — the version that ships with Boot 4 — BigDecimal defaults to being stored as a string. Round-tripping through the repository looks perfect: save 129.99, read back 129.99, tests green. But in the database it is "129.99", and the moment you ask Mongo to reason about the value, things quietly go wrong. findByPriceGreaterThan(100) ends up comparing strings lexicographically, and an aggregation that multiplies price by quantity has nothing numeric to multiply.
That is the worst kind of failure — no exception, just wrong answers. So: money is BigDecimal in Java and Decimal128 in BSON, end to end, and I now treat that annotation as non-negotiable.
Queries: three levels, and you drop down only when you need to
Level one is Spring Data doing what it already does in JPA — parsing method names into queries. What surprised me is how far that reaches into a document:
List<Product> findByTagsContaining(String tag); // { tags: "keyboard" } - array contains
List<Order> findByLinesProductId(String productId); // { "lines.productId": ... }
The second one is the document model showing off. Lines_ProductId becomes the dotted path lines.productId, and it matches any order containing a line for that product — reaching straight into an embedded array, with no join table and no EXISTS subquery.
Level two, for when method names get silly, is the raw query document — the actual MongoDB query language, the thing you would type in the shell:
@Query("{ 'price': { $gte: ?0, $lte: ?1 }, 'tags': { $in: ?2 } }")
List<Product> search(BigDecimal min, BigDecimal max, List<String> tags);
Level three is the aggregation pipeline, where MongoDB stops being "JSON in a bucket" and starts being a database. Revenue per product, across orders whose lines are buried inside documents:
Aggregation agg = Aggregation.newAggregation(
Aggregation.unwind("lines"), // one row per line
Aggregation.project()
.and("lines.productName").as("productName")
.andExpression("lines.unitPrice * lines.quantity").as("lineTotal"),
Aggregation.group("productName").sum("lineTotal").as("revenue"),
Aggregation.sort(Sort.Direction.DESC, "revenue")
);
Read it as SQL and it is a GROUP BY with a SUM. The difference is $unwind: because the lines are nested, you first have to flatten the array back into something row-shaped. Embedding is not free — you pay for it exactly here, at analytics time. That is the trade, stated plainly: cheap reads of whole aggregates, more work when you want to slice across them.
What I actually take away from day one
- Model for the read. "What does the application ask for in one go?" replaces "what are the entities?" as the first question. Store together what you read together.
- Integrity is now my job.
customerIdis a foreign key that nobody enforces. Mongo will happily let an order point at a customer that does not exist. Coming from regulated banking, where the database is the last line of defence, that is the thing I am watching most carefully. - Duplication can be a fact, not a smell. The snapshot in
OrderLineis the idea I will be chewing on the longest.
Next in the series: indexes, and what actually happens when I load a few hundred thousand documents and start reading query plans. That is where I expect my comfortable assumptions to break — which is exactly why I want to be there.