Designing an ecommerce database involves modeling products, customers, orders, and inventory with relationships that preserve data integrity, while safe checkout patterns ensure that order creation, payment capture, and stock decrement happen atomically to prevent overselling and data corruption. This article explains the core tables, their relationships, and the transactional patterns that keep checkout reliable under concurrency.
Core Entities and Their Relationships
An ecommerce database typically starts with five core entities: customers, products, orders, order_items, and inventory. Each has a primary key (usually an auto-incrementing integer or UUID) and foreign keys that link related rows.
- customers: id, email, name, created_at
- products: id, name, description, price (in cents or smallest unit), sku
- orders: id, customer_id (FK), status, total, created_at
- order_items: id, order_id (FK), product_id (FK), quantity, unit_price
- inventory: product_id (FK), quantity_available, reserved
The relationship between orders and products is many-to-many, resolved by the junction table order_items. Each order belongs to one customer (one-to-many), and each product can appear in many orders. Inventory is one-to-one with products.
Why Normalization Matters
Normalization (typically to Third Normal Form) eliminates redundant data and prevents update anomalies. For example, storing unit_price on order_items rather than relying on the product’s current price preserves the historical price at the time of purchase. Without this, changing a product’s price later would alter past order totals.
However, over-normalization can hurt performance. A common compromise is to denormalize aggregate values like order_total on the orders table, updated transactionally, to avoid recomputing sums on every read. Similarly, inventory counts are often stored as a single integer column rather than a separate normalized table for simplicity, though more complex systems use ledger tables for auditability.
Designing the Checkout Flow
Checkout is a multi-step process: validate cart, create order, charge payment, decrement inventory, and confirm. The critical requirement is that these steps either all succeed or all fail. This is achieved with database transactions and row-level locking.
- Begin a transaction with isolation level
READ COMMITTEDorREPEATABLE READ. - Lock the inventory rows for the products being purchased using
SELECT ... FOR UPDATE. - Check if sufficient stock exists; if not, rollback and return an error.
- Insert the order and its items.
- Decrement the inventory quantity.
- Commit the transaction.
The payment call (e.g., to a gateway) should be made after the order is created but before the final commit, or as a separate step with idempotency keys. The safest pattern is to create the order in a ‘pending’ state, call the payment provider, and then update the order to ‘paid’ and decrement inventory in a second transaction. If the payment fails, the order can be canceled and no stock is lost.
Handling Concurrency and Overselling
Overselling occurs when two users simultaneously purchase the last item. Without locking, both transactions could read the same stock level and both decrement it, resulting in negative inventory. The SELECT ... FOR UPDATE lock ensures that the second transaction waits until the first commits, then sees the updated quantity.
An alternative is optimistic locking using a version column:
UPDATE inventory SET quantity = quantity - 1, version = version + 1 WHERE product_id = ? AND version = ?
If the affected row count is zero, the update failed because another transaction changed the version, and the checkout is retried or aborted. This pattern is useful in high-read, low-write scenarios but requires handling retries.
Safe Payment and Idempotency
Payment gateways can return errors after actually charging the card, or the network can drop the response. To avoid double-charging, every payment attempt should include an idempotency key—a unique string (often the order ID) that the gateway uses to recognize duplicate requests and return the original result.
In the database, store the payment transaction ID and status on the order. If the payment callback arrives twice, the second one can be ignored because the order is already marked as paid. This pattern is essential for webhook reliability.
Common Pitfalls and Mistakes
- Using floating-point for money: Always store monetary values as integers (cents) or
DECIMAL(10,2). Floating-point arithmetic can introduce rounding errors. - Not using foreign keys: Foreign keys enforce referential integrity. Without them, orphaned order items can appear if a product is deleted.
- Deleting products: Instead of hard-deleting, use a soft-delete flag (
is_active) to preserve historical order references. - Ignoring transaction isolation: The default isolation level in MySQL (REPEATABLE READ) can cause phantom reads; understand your database’s behavior.
- Forgetting to index foreign keys: Joins on unindexed foreign keys will be slow as data grows.
Scaling Considerations
As traffic grows, the single-database approach may become a bottleneck. Common strategies include:
- Read replicas: Offload read-only queries (product listings, order history) to replicas, but ensure checkout writes go to the primary.
- Sharding: Split data by customer ID or order ID, but beware that cross-shard transactions become complex.
- Queue-based order processing: Accept the order request, return a ‘processing’ status, and use a message queue to handle payment and inventory asynchronously. This improves responsiveness but requires careful reconciliation.
For most small to medium stores, a well-indexed relational database with proper transactions is sufficient. The key is to avoid premature complexity.
Frequently Asked Questions
Should I use a relational database or NoSQL for ecommerce?
Relational databases (PostgreSQL, MySQL) are the standard choice because they provide ACID transactions, which are critical for orders and inventory. NoSQL databases can be used for product catalogs or sessions, but you will still need a relational system for transactional data.
How do I handle cart abandonment?
Store cart contents in a separate table or in the session, and periodically clean up abandoned carts. You can also send reminder emails, but that is outside the database design.
What is the best way to store product variants (size, color)?
Use a product_variants table with a foreign key to products, and store variant-specific attributes (size, color) and inventory. The order_items table references the variant ID, not the product ID.
How do I handle refunds and cancellations?
Add a status column to orders (e.g., ‘pending’, ‘paid’, ‘shipped’, ‘refunded’). For refunds, create a separate refunds table linked to the order, and increment inventory back if the items are returned.
Can I use an ORM and still maintain safe transactions?
Yes, most ORMs (e.g., Django ORM, SQLAlchemy, Prisma) provide transaction decorators/context managers. However, you must ensure that the code within the transaction uses the same session/connection, and that you explicitly lock rows when needed.
What is the difference between SELECT FOR UPDATE and optimistic locking?
SELECT FOR UPDATE locks the row until the transaction ends, preventing others from reading or writing it. Optimistic locking does not lock; it checks a version number at update time. Pessimistic locking is simpler but reduces concurrency; optimistic locking is better for low-contention scenarios.
Related
Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.
More cards in this drawer
- China’s IPv9 Decimal Network Project Explained
- Fix Wired Earbuds That Only Work in One Ear
- What to Check Before Buying the Dip in Indian Shares
- Cool Raspberry Pi Projects: 15 Ideas for Any Skill Level3
- Terraform vs Kubernetes: Key Differences
- Internet Connectivity in Remote Areas: Options0.80 USD/GB