Data Modeling in NoSQL: Forget the Table, Think About the Screen
In a relational database you normalize the data; in a document database you model the screen. Here is the modeling approach I settled on after paying the price of understanding that difference too late.
When you walk into a document database with relational habits, everything seems to be going fine — right up until you need data from five different places to fill a single screen. I hit that wall on my first project and had to rebuild the model from scratch.
The problem was this: I had modeled the data the way I learned to in the relational world. Users in one place, listings in another, categories separate, comments separate. Everything spotless, no data repeated anywhere. Then I built a listing feed screen and had to show the owner's name and photo on every row. Twenty listings, twenty extra reads.
The basic rule: start with the query, not the data
In relational design you first work out the entities and relations; the queries come later, because JOIN can stitch everything together. In a document database you have no such safety net. So the process runs in reverse:
- What screens does the app have?
- What data does each screen show, in what order, with what filter?
- What document structure fills that screen with a single query?
In other words, the data model takes the shape of the screens, not the entities. That feels wrong at first — because the same information sits in more than one place. But in document-based systems storage is cheap and reads are expensive. The optimization target changes.
Denormalization: copying is a decision, not a mistake
What I did in the listing feed example above was copy the owner's name and photo URL into the listing document itself:
ilanlar/{ilanId}
{
"baslik": "Kadıköy'den Ataşehir'e",
"tarih": "2026-07-02T08:30:00Z",
"fiyat": 120,
"durum": "aktif",
"sahip": {
"id": "u_918",
"ad": "Demir T.",
"fotograf": "https://.../u918.jpg"
}
}
Now the feed screen fills with a single query. But a new problem appears: when a user changes their name, the copy in every one of their listings goes stale.
The answer to that problem is not "then let's not copy." The answer is a question: how much damage does stale data actually do here?
| Data | Change frequency | Going stale | My decision |
|---|---|---|---|
| User name, avatar | Rare | Tolerable | Copy, update in the background |
| Listing price | Medium | Unacceptable | Keep in a single source |
| Category name | Very rare | Tolerable | Copy |
| Balance, stock | Frequent | Unacceptable | Never copy |
To keep the copied fields up to date, I use a background job that fires when the source document changes. When a user changes their name, that user's listings are updated in bulk. It is not instant, but it becomes consistent within a few seconds — and those few seconds bother no one.
The reason I use a nested object like sahip.ad inside the document is not just tidiness: the naming makes it clear that these fields are a copy. A flat sahipAdi field eventually raises the question "is this the real source?" Keeping the copies together tells whoever reads the code later — and that person is usually you, six months from now — which data is derived.
Subcollection, array, or separate collection
This is the decision people make most often, and the one they get wrong most often. The test I use comes down to three questions:
- How many can there be? If the count can grow without bound, an array is out. Document size has an upper limit, and one day you will hit it.
- Is it always needed when reading the parent document? If it is, and the count is small, an array makes sense — you avoid an extra read.
- Is it queried on its own? If there is a query like "the latest comments across all users," the data belongs in a root collection, not a subcollection.
The split I have settled on in practice: a listing's tags go in an array (few of them, always read together). A listing's offers go in a subcollection (there can be many, and they are queried separately). Notifications live in a separate root-level collection, because they are filtered by user and are not tied directly to a listing.
Counters: the sneakiest problem
Counting the subcollection to answer "how many offers did this listing get" looks natural at first glance. But doing that count for every listing on every feed screen is both slow and expensive.
The fix is to keep the number inside the document:
ilanlar/{ilanId}
{
"baslik": "...",
"teklifSayisi": 14,
"sonTeklifTarihi": "2026-07-02T09:12:00Z"
}
Updating that number by hand is a source of bugs. I add and remove offers together with the counter inside a single transaction: either both happen or neither does. Otherwise, when the app closes midway through, the number stops matching reality — and noticing that can take months.
A single counter document that many users update at the same time becomes a bottleneck — there is a limit on write throughput per document. For very high-traffic counters you have to split the number into several shards and sum them on read. You do not need this for every counter; only for the genuinely hot ones.
Do not add a field without thinking about the query
Query capabilities in document-based systems are limited: range filters on more than one field, complex OR combinations, and searching inside text are either impossible or require a dedicated index.
So I store the fields I plan to filter on in a filter-friendly form:
- Keeping dates used for sorting in a single comparable format
- Merging frequently used filter combinations into one composite field — something like
"aktif_kadikoy" - Not trying to make the database do text search; setting up a separate path for search instead
The composite field in the second item looks ugly at first. But it is far cheaper than the index you would need to filter on two separate fields, and its performance is more predictable.
When you have to change the model
Having no schema does not mean there will be no schema changes — it only means the database does not enforce the change, you manage it. The path I follow when adding a new field:
- Start writing the new field, and keep writing the old one too.
- On the read side, use the new field if it exists, otherwise fall back to the old one.
- Convert existing documents in bulk in the background.
- Once old app versions in the field have dropped off enough, stop writing the old field.
- Finally, delete the old field.
Five steps looks like a lot, but it is mandatory on mobile: the old version on the user's phone has to keep working after you change the data.
Summary
There is exactly one habit to drop when moving to a document database: the reflex against repeating data. The habit to put in its place is asking, on every modeling decision, "which screen does this feed, and how many reads does that screen cost?" The right model is not the one that looks cleanest, but the one that fills the screen with the least work.
- Backend
- Database
- NoSQL
- Architecture
- Firebase
Demir Taşdemir
Mobile App & Web Developer
I have been building software since 2018. I have shipped 11 apps on the App Store and Google Play; right now I am working on 6 mobile apps, 1 e-commerce platform and 1 desktop game.
Is your data model getting harder to work with as it grows?
In a document database, modeling decisions are the most expensive ones to change later. We can review your current structure and work out where it is getting stuck. Feel free to write to me from the contact page.