Relational Data Modeling for CRUD Systems
LESSON
Relational Data Modeling for CRUD Systems
By the end of this lesson, you will be able to...
Turn a small backend workflow into tables, primary keys, foreign keys, and constraints.
Distinguish current facts, historical snapshots, and accidental duplicated data.
Review a CRUD data model for unclear ownership, missing relationships, and unsafe deletes.
Idea in one sentence: A relational model gives each durable fact one clear home, then connects facts with keys so CRUD handlers can change state without quietly corrupting it.
Core Insight
The orders API now has a runtime, dependencies, and a small request path. The next question is where the backend should keep durable state.
The first version is tempting:
POST /orders creates one big JSON object.
GET /orders/1001 returns the same object.
PATCH /orders/1001 changes fields inside it.
That feels simple because one request maps to one stored blob.
Then normal product behavior arrives:
A customer changes their email.
A product price changes for future orders.
An old invoice must still show the price paid.
Support needs all open orders for one customer.
A product is no longer sold, but old orders still mention it.
Now the big object is less clear. Is the email inside the order the current customer email, or the email at purchase time? If the product name changes, should old orders change too? If a product is deleted, what happens to order history?
The naive idea is:
Store whatever shape the API returns.
That works while the product has one screen and one operation. It breaks when different facts change for different reasons.
Relational data modeling asks a steadier question:
Which facts exist?
Who owns each fact?
How do facts point to each other?
Which invalid states should the database refuse?
This is a design lesson, not a SQL syntax lesson. The next lesson will focus on query shape, indexes, and transactions. Here we design the shape of durable truth.
The Small Situation
Use one recurring workflow:
Customers buy products.
Each order has one customer.
Each order has one or more line items.
Each line item names a product and a quantity.
Old orders must remain explainable after product data changes.
The backend needs common CRUD operations:
- Create a customer.
- Create an order for that customer.
- Add order items.
- Read an order summary.
- Update a customer's email.
- Stop selling a product.
- Cancel an order before shipment.
CRUD means create, read, update, and delete. CRUD is not the model. CRUD is pressure on the model.
If the model is clear, each handler knows what it may change. If the model is blurry, each handler invents its own version of truth.
The visible pieces are:
- An entity is a thing the system remembers, such as a customer, product, or order.
- An attribute is a fact about an entity, such as
email,status, orcreated_at. - A table stores rows for one kind of entity or relationship.
- A primary key gives each row stable identity.
- A foreign key points from one row to another row.
- A constraint is a rule the database enforces.
- Normalization stores each durable fact in one appropriate place, then connects facts with keys.
Plain meaning:
Normalization means not storing the same fact in five places unless you have a specific reason.
In this scenario:
The current customer email should live in one customer row. The purchase-time price should live with the order item because it is part of order history.
Technical name:
This separation of facts by identity and meaning is relational normalization.
The Naive Design
Start with one large table:
orders
order_id
customer_name
customer_email
shipping_street
shipping_city
product_1_name
product_1_price_cents
product_1_quantity
product_2_name
product_2_price_cents
product_2_quantity
order_status
This design is attractive because it matches one response:
GET /orders/1001
It is also easy to insert at the beginning. One handler writes one row.
The trouble appears when the workflow changes even a little.
If an order has three products, the table needs new columns or a strange workaround. If a customer changes email, the backend must decide whether to update old order rows. If product price changes, old charged prices may change by accident. If support needs all orders for one customer, the query relies on copied customer fields instead of customer identity.
The deeper problem is not that the table is ugly. The deeper problem is that ownership is unclear.
Who owns customer_email?
Who owns the product price?
Who owns order_status?
Is product_1_name current catalog data or historical invoice data?
When a model cannot answer ownership questions, CRUD code becomes guesswork.
A Better Boundary Between Facts
A relational first pass separates facts by what they are and how they change:
customers
customer_id
email
display_name
created_at
addresses
address_id
customer_id -> customers.customer_id
line_1
city
country
postal_code
products
product_id
sku
name
current_price_cents
active
orders
order_id
customer_id -> customers.customer_id
shipping_address_id -> addresses.address_id
status
created_at
order_items
order_item_id
order_id -> orders.order_id
product_id -> products.product_id
quantity
unit_price_cents_at_purchase
This is not the only possible model. It is a useful first model because every table has a job.
customers owns current customer account facts.
products owns current catalog facts.
orders owns the lifecycle of one purchase.
order_items owns the relationship between one order and one product.
The order_items table is the important beginner step. An order can include many products. A product can appear in many orders. The relationship itself has facts: quantity and price paid at purchase time.
When a relationship has its own facts, it usually deserves its own table.
Check: Where should quantity live: on products, on orders, or on order_items?
Think first, then reveal.
Answer: order_items. Quantity is not a fact about the product in general, and it is not a fact about the whole order. It is a fact about one product inside one order.
A Worked Create Path
Trace one request through the model:
POST /orders
customer_id: 42
shipping_address_id: 12
items:
- product_id: 7, quantity: 2
- product_id: 9, quantity: 1
The handler should not simply trust that these IDs form a valid order. The model tells it what to verify and write.
Input:
customer 42 wants products 7 and 9 shipped to address 12
Step 1: verify customer identity
customers.customer_id = 42 exists
Step 2: verify address ownership
addresses.address_id = 12 exists
addresses.customer_id = 42
Intermediate state:
the address is not only valid; it belongs to this customer
Step 3: verify product availability
products 7 and 9 exist
products.active = true
Step 4: create the order
insert one row into orders
status = 'pending'
customer_id = 42
shipping_address_id = 12
Step 5: create line items
insert one row per requested product into order_items
copy current_price_cents into unit_price_cents_at_purchase
Output:
order 1001 exists with two line items and stable purchase-time prices
Now compare the naive failure.
If the backend only stored a big order blob, a future product price update might overwrite what the customer paid. Or the old order might keep a copied price, but the model would not say whether that copy is intentional.
The relational model makes the decision visible:
products.current_price_cents = current catalog fact
order_items.unit_price_cents_at_purchase = historical purchase fact
The duplicate-looking price is not accidental duplication. It is a snapshot with a reason.
So far, the model has done two useful things. It gave current facts a home, and it named the historical facts that should not change when current facts change.
Relationships and Cardinality
Relationships describe how rows connect. Cardinality means the "how many" shape of a relationship.
In the orders workflow:
- One customer can have many orders.
- One order belongs to one customer.
- One order can have many order items.
- One product can appear in many order items.
- One address can be used by many orders, unless the system snapshots shipping details.
These sentences are design decisions. They become foreign keys and table boundaries.
If one customer can have many orders, orders carries customer_id.
If one order can have many products, orders cannot have a single product_id.
If one order/product pair has quantity and purchase price, the pair needs a row in order_items.
This is where a relational model becomes more than a diagram. It turns product language into enforceable structure.
Constraints Make the Design Real
A diagram can say that an order item belongs to an order. The database can enforce it with a foreign key.
Useful constraints include:
NOT NULL: a required value must be present.UNIQUE: no two rows may share the same value or combination.FOREIGN KEY: a referenced row must exist.CHECK: a value must satisfy a rule, such asquantity > 0.DEFAULT: a value is supplied when an insert omits it.
For this model, constraints might say:
customers.email is UNIQUE and NOT NULL
orders.customer_id references customers.customer_id
order_items.order_id references orders.order_id
order_items.product_id references products.product_id
order_items.quantity must be greater than 0
orders.status must be one of pending, paid, shipped, canceled
Application validation still matters. It gives clients good error messages and protects the user experience.
Database constraints matter for a different reason. They protect durable state across every code path: public API, admin tool, migration script, background job, and future handler written by someone who has not read today's code.
Plain meaning:
Integrity means the stored data still obeys the rules that make it meaningful.
In this scenario:
An order item with quantity = -3 is not a valid order item, even if one buggy route tries to write it.
Technical name:
This is data integrity. Referential integrity means references point to rows that exist. Domain integrity means values fit their allowed meaning.
CRUD Through the Model
Run CRUD again, now with ownership visible.
Create:
POST /orders creates one orders row and several order_items rows. It points to existing customer, address, and product rows. It snapshots purchase-time prices.
Read:
GET /orders/1001 reads the order, its items, and product display details. If the response needs the charged price, it reads order_items.unit_price_cents_at_purchase, not products.current_price_cents.
Update:
PATCH /customers/42 changes customers.email. Current customer views should show the new email. Old order prices do not change because they are historical facts. If invoices need the original contact email, add an explicit invoice contact snapshot.
Delete:
Deleting is often not physical removal. If a product has appeared in old orders, removing its row may break history. A safer model often uses active = false, removed_at, or a status transition. The product stops appearing in new checkout flows, but old orders remain explainable.
The route name says what operation the user requested. The model says what facts the operation is allowed to change.
Check: A product is no longer sold. Should the backend physically delete the product row immediately?
Think first, then reveal.
Answer: Usually no. Old order items may still need to point to the product for history, support, and reporting. A status such as active = false is often safer than physical deletion.
Trade-offs and Limits
Normalization helps when facts change at different speeds. Customer profile data, catalog data, order status, and purchase history do not all have the same lifecycle.
The trade-off is clear: normalization buys safer writes and clearer ownership, but it can make reads and reporting more expensive. Reads may require joins. Developers need consistent names. Some API responses need assembly from several tables. Reporting may need a separate read model, cache, or warehouse table.
That does not make normalization bad. It means the design should be explicit.
The risky move is accidental denormalization:
We copied product_name into orders because it was convenient.
Nobody knows whether it is a snapshot, cache, or mistake.
Each copied value needs a rule:
- A snapshot preserves history and should not automatically change.
- A cache can be rebuilt from source truth.
- A mistaken copy has unclear ownership and will drift.
This model does not solve every database problem. It does not choose indexes. It does not make complex queries fast by itself. It does not remove the need for transactions. It gives the next layer a clean structure to work with.
You can see the boundary when reads become slow because they assemble many relationships, or when reporting needs data shaped differently from the write model. That is a signal to design a read path deliberately, not to erase ownership from the write model.
Common Confusions
Confusion: The API Response Should Match One Table
Why it is tempting:
One endpoint returns one JSON object, so one table feels natural.
Better model:
An API response is a view assembled for a client. Storage should be organized around durable facts and ownership.
Confusion: Normalization Means No Copied Values Ever
Why it is tempting:
Beginners often hear "avoid duplication" as an absolute rule.
Better model:
Avoid accidental duplication. Intentional snapshots are valid when history must stay stable.
Confusion: Application Validation Is Enough
Why it is tempting:
The public API handler already checks input, so constraints may feel redundant.
Better model:
Application validation protects one entry point. Database constraints protect durable state across all entry points.
Practice
Review this feature:
Customers can save many payment methods.
An order uses exactly one payment method at checkout.
Support must see which payment method was used.
The system must never store a full card number.
Customers can remove a payment method from future checkout.
Design a small relational model.
A good answer should mention:
- At least three tables, such as
customers,payment_methods, andorders. - A relationship from payment methods to customers.
- A decision about what the order stores: a reference, a safe snapshot, or both.
- A safe deletion rule, such as
removed_atinstead of immediate physical deletion. - A constraint that protects the model, such as a foreign key or a check that only safe card display data is stored.
Model answer:
payment_methods belongs to customers. An orders row can reference the payment method used at checkout and may also store safe display data such as brand and last four digits. The full card number should not be stored in this backend model. Removing a payment method should usually prevent future use without breaking old order history, so a status or removed_at timestamp is safer than deleting the row immediately. Useful constraints include foreign keys from payment methods to customers and orders to customers, plus NOT NULL fields for the safe display data the support workflow requires.
Connections
The previous lesson treated dependencies as code that crosses your backend boundary. This lesson treats database rows as facts that cross time. In both cases, the backend needs clear ownership.
The next lesson uses this model to ask a different question: given these tables, what work does a SQL query cause, and when do indexes or transactions change the outcome?
Resources
- [DOC] PostgreSQL Documentation: Data Definition
- Link: https://www.postgresql.org/docs/current/ddl.html
- Focus: Connect tables, defaults, constraints, and generated columns to real database features.
- [DOC] PostgreSQL Documentation: Constraints
- Link: https://www.postgresql.org/docs/current/ddl-constraints.html
- Focus: Read how primary keys, foreign keys, unique constraints, and checks protect stored data.
- [BOOK] Database Design for Mere Mortals
- Link: https://www.oreilly.com/library/view/database-design-for/9780136788041/
- Focus: Use this as a beginner-friendly deeper source on entities, relationships, keys, and normalization.
- [REFERENCE] Martin Fowler: Patterns of Enterprise Application Architecture Catalog
- Link: https://martinfowler.com/eaaCatalog/
- Focus: Skim Identity Field and Foreign Key Mapping to see how application objects and relational rows meet.
Key Takeaways
- Relational modeling starts from durable facts, not from one convenient API response.
- Tables own facts, primary keys identify rows, foreign keys connect rows, and constraints reject invalid state.
- A copied value can be correct when it is an intentional snapshot; it is dangerous when nobody knows who owns it.
- CRUD handlers become safer when the model makes clear what each operation may create, read, update, retire, or preserve.
← Back to Backend Development Foundations