Ch 6 / 24 Access Patterns Before Schemas 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Software Engineering for AI Engineers/Chapter 6

Part 2 · Managing Data · Chapter 6

Access Patterns Before Schemas

Ng gives data its own section for a reason: it is the foundation everything sits on and the hardest thing to change afterwards. Most bad schemas were designed by listing the nouns in a product brief. Good ones are designed by listing the questions the system will be asked, in the order it will be asked them, with how often attached.

Reading
Kleppmann, DDIA Ch. 2
Focus
Queries before tables
Runs
Real SQLite, in this page
Exercises
12 · SQL + design

By the end of this chapter you can

  1. Write an access-pattern table before writing a line of DDL
  2. Extract the query hiding inside a product requirement
  3. Attach a frequency and a latency requirement to each pattern, and use them
  4. Compare two schemas by what each one makes hard rather than by which looks tidier
  5. Name the question a schema cannot answer, which is the cost nobody records
  6. Recognise when a pattern is telling you this is not relational data at all

1Nouns produce bad schemas; questions produce good ones

The standard method is to read the brief, underline the nouns, and make a table for each one. It is fast, it feels like modelling, and it reliably produces a schema that answers the questions nobody asks and struggles with the ones everybody does.

Here is why. A noun tells you what exists. It tells you nothing about how it will be reached, how often, with what filters, sorted how, or how fresh the answer must be. Those five things decide the design, and none of them is in the noun.

Consider a brief containing “invoices”, “customers” and “payments”. Three nouns, three tables, and you are done in five minutes. Now ask what the product actually does with them:

The question the product asksHow oftenWhat it implies
Show this customer their unpaid invoices, newest firstEvery page loadFilter by customer and status, sorted by date — the single most important shape
How much is this organisation owed, by age band?Every dashboard loadAn aggregate over a computed bucket, which is not a column
Which invoices became overdue today?Once a day, in a jobA range scan on a date — cheap, and only if the date is stored rather than derived
What is this customer’s average time to pay?Rarely, in a reportNeeds both an issue date and a payment date on the same row, or a join nobody planned
Show every invoice mentioning “consultancy”OccasionallyNot a relational question at all. This one is a search index, and finding that out now is worth a week.
The idea to keep

A schema is not a description of your domain. It is a bet about which questions will be asked. Writing the questions down first makes the bet explicit, which is the only way to notice later that you bet wrong — and, more usefully, the only way to notice now that one of the questions belongs in a different kind of store entirely.

2The access-pattern table

Five columns, one row per question. It takes twenty minutes and it is the highest-value twenty minutes in the whole design.

access-patterns.md — Ledger
#  question                          freq      latency  freshness
1  invoices for customer, newest first  every load  <150ms   live
2  amount owed by age band, per org     every load  <300ms   60s ok
3  invoices going overdue today         1/day       any      live
4  average days-to-pay per customer     1/week      any       24h ok
5  full-text search over line items     rare        <500ms   5min ok

Consequences
- 1 is the hot path. It decides the primary index and the row shape.
- 2 buckets on a value that is not stored. Either compute it or store it.
- 3 needs due_on as a stored column, not derived at query time.
- 4 needs issued_on and paid_on on the same row, or an expensive join.
- 5 is not a relational question. Chapter 10 gives it somewhere to live.

Three things make this document earn its place. Frequency tells you which pattern is allowed to be slow — the weekly report can take four seconds and nobody will ever know. Latency turns “fast” into a number that a test can check. And freshness is the caching decision from Chapter 5, arriving before there is anything to cache, which is when it is cheapest to make.

The last section is the one people skip and the one that pays. Pattern 5 does not belong in the database at all, and noticing it here costs nothing. Noticing it after six months of LIKE '%consultancy%' costs a migration.

3Two schemas from one brief

The best way to feel this is to run the same questions against two designs. The database below is real SQLite, running in this page, and the queries are genuinely executed.

Both schemas hold the same facts. One was designed from the nouns; the other from the questions. Neither is more “normalised” than the other in any strict sense — the difference is which questions they anticipated.

That query is correct and it is doing three things the product will do on every page load: summing line items to discover a total, checking for the absence of a payment to discover a status, and sorting. None of those results is stored, so none of them can be indexed, and all three get slower with the size of the account.

Now the same questions against a schema designed from them:

4The query hiding inside a requirement

Product requirements almost never contain a query. They contain a sentence from which exactly one query follows, and getting it out is a skill you can practise.

The method is three questions, asked of every requirement:

  1. What am I filtering on? These become the leading columns of an index. If the answer is “whatever the user typed”, that is a search problem rather than a filter.
  2. What am I sorting by? A sort on an unindexed column means the database gathers every matching row before it can return the first one, which is why an endpoint can be fast with a hundred rows and hopeless with a hundred thousand.
  3. What am I aggregating? A sum or a count over a set that grows with the customer is the single most common source of a query that ages badly.

Run those three questions over “the dashboard should show how much each organisation is owed, split by how overdue it is” and you get: filter on organisation and status, no sort, aggregate a sum over a bucket derived from a date. The bucket is the interesting part — it is not a column, so either it gets computed per query or it gets stored, and that is a decision with a maintenance cost attached.

The specific mistake

Modelling a requirement that says “search” as a LIKE filter. A leading wildcard cannot use a B-tree index, so WHERE description LIKE '%consultancy%' reads every row in the table, every time. The symptom is a search box that works beautifully in development and becomes the slowest endpoint in the product within a year, and by then it is on a page nobody wants to remove. Chapter 10 gives search somewhere proper to live; Chapter 8 explains exactly why the index cannot help.

5What a schema forbids

The interesting property of a design is not what it supports. It is what it makes impossible, and that list is never written down.

Every modelling choice closes doors. Storing a status column forbids asking “what was the status last Tuesday?” unless you also keep history. Storing a customer’s address on the invoice makes historical invoices correct forever and forbids updating an address in one place. Choosing a single total_pence forbids multi-currency without a migration.

None of those is wrong. All of them are worth writing down, because the future request that runs into one of them will arrive as “can we just…”, and the honest answer will be a number of weeks that nobody understands.

The choiceWhat it buysWhat it forbids
Status as a stored columnAn indexable, sortable hot pathAsking what the status was at any past moment
Address on the customer, not the invoiceOne place to update itReprinting a two-year-old invoice as it was sent
Address copied onto the invoiceHistorical accuracy foreverFixing a typo everywhere at once
One total_pence integerExact money arithmetic, no float errorMulti-currency, without a migration
Hard delete on cancellationA simple, small tableAny question that begins “how many did we cancel…”
Line items in a JSON columnFlexible shape, one row per invoiceAggregating across line items without scanning every invoice

6Build the table

The exercise below is the one worth repeating on every project. It is short, it is boring, and it prevents the three failures above.

Take the brief for anything you are about to build and write out, for each thing the system will be asked: the question in plain English, the filter, the sort, the aggregate, how often, how fast, and how fresh. Then read the list and mark the ones that are not relational questions.

Two rules make the output useful rather than decorative. First, order the rows by frequency, so the hot path is at the top and it is obvious what the schema is optimising for. Second, write a consequences section: for each pattern, one line saying what it implies about the design. A table with no consequences section is a list of requirements; a table with one is a design document.

The idea to keep

An agent will happily generate a schema from your brief in four seconds, and it will be a competent schema for the questions it guessed. Handing it the access-pattern table instead changes the output completely, because the table contains the information the brief did not: what this data is going to be asked, and how often. That is the steering this whole course is about, applied to the part of the system that is hardest to change afterwards.

Tonight

Take a table from something you have built and write down three questions it answers well and one it answers badly. Then work out whether the bad one is a missing index, a missing column, or a question that never belonged in that table. It is usually the third, and the fix is usually smaller than it looks once you have named it.

Checkpoint

Five questions. Commit before you read the explanation.

Playground

A full Ledger dataset, open. Write anything. The suggestions in the comments are the five access patterns from section 2 — try each one and notice which are easy and which are awkward.

Exercise set

Twelve problems. Most of them are SQL, executed against a real SQLite build seeded with the Ledger dataset — the row counts and the error messages are the ones SQLite produced. Press Run to see your result set before you press Check. Your work is saved in this browser.

All Warm-up Core Challenge Reset chapter

Chapter 7 — Relational Modelling That Holds Up

A constraint is a rule the database enforces at three in the morning when nobody is watching. Application code is a rule you hope everyone remembers.

Continue →