Introduction
When a traditional retail business decides to go online, the jump is not just about building a website or a shopping cart. Behind every "your order is on its way" notification lies a carefully engineered data system one that tracks products, manages stock across multiple locations, handles customer accounts, processes payments, and routes each order to the right warehouse.
This project Order Fulfilment Database for an E-Commerce Platform models exactly that. It's a relational database designed for a UK-based retail business expanding into e-commerce, built to support Amazon-style same-day and next-day delivery by intelligently linking customer orders with the nearest fulfilment centre that holds the required stock.
In this blog, i will walk through the full project: the business problem it solves, the database schema it uses, the design decisions behind it, and the queries that make it all tick.
The Business Problem
Imagine a retail company with multiple physical stores and warehouses scattered across the UK. They have decided to launch an online platform that lets customers order products for home delivery. The challenge? Delivering quickly and efficiently which means:
- Knowing what products are available and where they are stored
- Matching a customer's delivery postcode to the nearest warehouse that has the product
- Estimating a delivery time based on warehouse-to-postcode proximity
- Tracking every order item at the individual stock level
This is the logistics problem at the heart of the project, and the database schema is designed to solve it.
Database Schema Overview
The project contains three SQL files:
tables_creation.sql— defines all 9 tables and their constraintsdata_insertion.sql— populates the database with sample recordsselection_queries.sql— example queries that demonstrate the database in action
The Tables in Detail
1. categories and brands
CREATE TABLE categories (
category_id INT NOT NULL,
category_name VARCHAR(64) NOT NULL,
CONSTRAINT categories_PK PRIMARY KEY (category_id),
CONSTRAINT category_name_unique UNIQUE (category_name)
);
CREATE TABLE brands (
brand_id INT NOT NULL,
brand_name VARCHAR(64) NOT NULL,
CONSTRAINT brands_PK PRIMARY KEY (brand_id),
CONSTRAINT brand_name_unique UNIQUE (brand_name)
);These are simple lookup tables that exist to avoid repeating string values inside the products table. Each category and brand name is stored only once and referenced by a foreign key. This is a classic example of database normalisation specifically Third Normal Form (3NF), where repeated non-key data is extracted into its own table.
The UNIQUE constraints on category_name and brand_name ensure there are no duplicates (e.g., two records both called "Electronics").
2. products
CREATE TABLE products (
product_id INT NOT NULL,
category_id INT NOT NULL,
brand_id INT NOT NULL,
product_name VARCHAR(255) NOT NULL,
product_description VARCHAR(255) NOT NULL,
product_price DECIMAL(10,2) NOT NULL,
product_image VARCHAR(255) NOT NULL,
product_pack_size INT NOT NULL,
product_weight DECIMAL(8,2) NOT NULL,
product_origin VARCHAR(32) NOT NULL,
product_added_date TIMESTAMP NOT NULL,
CONSTRAINT products_PK PRIMARY KEY (product_id),
CONSTRAINT products_categories_FK FOREIGN KEY(category_id)
REFERENCES categories(category_id) ON DELETE SET NULL,
CONSTRAINT products_brands_FK FOREIGN KEY(brand_id)
REFERENCES brands(brand_id) ON DELETE SET NULL,
CONSTRAINT product_price_check CHECK (product_price > 0),
CONSTRAINT product_pack_size_check CHECK (product_pack_size > 0),
CONSTRAINT product_weight_check CHECK (product_weight > 0)
);The products table is the product catalogue at the heart of the system. A few design decisions stand out:
DECIMAL(10,2)for price ensures monetary values are stored precisely, avoiding the floating-point rounding errors you'd get withFLOAT.ON DELETE SET NULLon the foreign keys tocategoriesandbrandsmeans that deleting a category or brand won't cascade and destroy all associated product records it simply nullifies the reference.CHECKconstraints enforce that price, pack size, and weight can never be zero or negative basic but important business rules enforced at the database level.
3. warehouses and postcode_areas
CREATE TABLE warehouses (
warehouse_id VARCHAR(10) NOT NULL,
warehouse_location VARCHAR(255) NOT NULL,
CONSTRAINT warehouse_PK PRIMARY KEY(warehouse_id)
);
CREATE TABLE postcode_areas (
postcode_area VARCHAR(5) NOT NULL,
postcode_location VARCHAR(64) NOT NULL,
CONSTRAINT postcode_areas_PK PRIMARY KEY(postcode_area),
CONSTRAINT postcode_area_check CHECK (LENGTH(postcode_area) >= 2)
);These two tables represent the geographic layer of the database. Warehouses are the physical fulfilment centres. Postcode areas are UK postcode prefixes (e.g., SW1, M1, EH4) that map to geographic regions.
Note that warehouse_id uses a VARCHAR(10) rather than an auto-incrementing integer this allows for human-readable codes like WH-LON-01, which is more practical in a logistics context.
4. postcode_area_coverage The Many-to-Many Bridge
CREATE TABLE postcode_area_coverage (
postcode_area VARCHAR(5) NOT NULL,
warehouse_id VARCHAR(10) NOT NULL,
delivery_time INT,
CONSTRAINT postcode_area_coverage_PK PRIMARY KEY (postcode_area, warehouse_id),
CONSTRAINT postcode_areas_warehouses_FK FOREIGN KEY (postcode_area)
REFERENCES postcode_areas(postcode_area) ON DELETE CASCADE,
CONSTRAINT postcode_areas_warehouses_FK2 FOREIGN KEY (warehouse_id)
REFERENCES warehouses(warehouse_id) ON DELETE CASCADE,
CONSTRAINT delivery_time_check CHECK (delivery_time >= 12)
);This is one of the most interesting tables in the schema. It solves the many-to-many relationship between warehouses and postcode areas: a warehouse can cover many postcode areas, and a postcode area can be covered by multiple warehouses.
The composite primary key (postcode_area, warehouse_id) elegantly enforces uniqueness you can not have duplicate entries for the same warehouse-postcode pair.
Most importantly, this table includes a delivery_time attribute — how many hours it takes for that warehouse to deliver to that postcode area. The CHECK (delivery_time >= 12) constraint establishes a minimum 12-hour delivery window. This is the engine behind the system's ability to estimate delivery times for each order.
5. customers, customer_addresses, and customer_payment_methods
These three tables model the customer side of the system. Addresses and payment methods are stored in separate tables, allowing a single customer to have multiple saved delivery addresses and payment cards exactly as you'd expect from a modern e-commerce platform. This avoids embedding repeated data into the customers table and cleanly supports one-to-many relationships.
6. orders
CREATE TABLE orders (
order_id INT NOT NULL,
customer_id INT NOT NULL,
address_id INT,
payment_method_id INT,
order_status VARCHAR(32) NOT NULL,
order_date TIMESTAMP NOT NULL,
CONSTRAINT orders_PK PRIMARY KEY (order_id),
CONSTRAINT orders_customers_FK FOREIGN KEY (customer_id)
REFERENCES customers(customer_id) ON DELETE CASCADE,
CONSTRAINT orders_customer_addresses_FK FOREIGN KEY (address_id)
REFERENCES customer_addresses(address_id) ON DELETE SET NULL,
CONSTRAINT orders_customer_payment_methods_FK FOREIGN KEY (payment_method_id)
REFERENCES customer_payment_methods(payment_method_id) ON DELETE SET NULL
);The orders table ties a customer, their delivery address, and their payment method together into a single transactional record. The order_status field (e.g., "pending", "dispatched", "delivered") tracks where the order is in the fulfilment pipeline.
The mix of ON DELETE CASCADE (for customer) and ON DELETE SET NULL (for address and payment method) is deliberate: if a customer is deleted, all their orders should go too. But if a customer removes a saved address or card, the historical order records should remain intact.
7. stock_items The Heart of the Fulfilment Logic
CREATE TABLE stock_items (
stock_id INT NOT NULL,
product_id INT NOT NULL,
warehouse_id VARCHAR(10) NOT NULL,
stock_location VARCHAR(64) NOT NULL,
stock_status INT NOT NULL,
order_id INT,
stock_added_date TIMESTAMP NOT NULL,
CONSTRAINT stock_items_PK PRIMARY KEY(stock_id),
CONSTRAINT stock_items_products_FK FOREIGN KEY(product_id)
REFERENCES products(product_id) ON DELETE SET NULL,
CONSTRAINT stock_items_warehouses_FK FOREIGN KEY(warehouse_id)
REFERENCES warehouses(warehouse_id) ON DELETE SET NULL,
CONSTRAINT stock_items_orders_FK FOREIGN KEY(order_id)
REFERENCES orders(order_id) ON DELETE CASCADE
);This is the most logistically significant table in the schema. Rather than tracking stock as a count (e.g., quantity = 50), it models every individual physical item as its own record. This is sometimes called a serialised inventory model.
The nullable order_id field is what links a physical stock item to an order when a customer places an order, a stock item is allocated to it by populating this field. This design makes several things easy to query:
- How many units of a product are available at a given warehouse?
- Which warehouse covering a customer's postcode has stock in hand?
- What items are currently allocated to a specific order?
The Entity Relationship Map
Here's a summary of the full relationship structure:
Category (1) ──< Product (M)
Brand (1) ──< Product (M)
Product (1) ──< StockItem (M)
Warehouse (1) ──< StockItem (M)
Warehouse (M) ──< postcode_area_coverage >── (M) PostcodeArea
PostcodeArea (1) ──< CustomerAddress (M)
Customer (1) ──< CustomerAddress (M)
Customer (1) ──< PaymentMethod (M)
Customer (1) ──< Order (M)
Order (1) ──< StockItem (M)
Order (M) ──> CustomerAddress (1)
Order (M) ──> PaymentMethod (1)The many-to-many between warehouses and postcode_areas (resolved via postcode_area_coverage) and the one-to-many between orders and stock_items are the two most important structural patterns in the schema.
The Queries
The selection_queries.sql file includes three queries that demonstrate the database's analytical capabilities.
Query 1: View All Stock Items
SELECT * FROM stock_items;Simple, but useful for operational visibility — a warehouse manager can see all stock records at a glance.
Query 2: Products in a Specific Order (with Brand and Category)
SELECT
products.product_id,
products.product_name,
brands.brand_name,
categories.category_name,
COUNT(stock_items.stock_id) AS quantity
FROM orders
INNER JOIN stock_items ON orders.order_id = stock_items.order_id
INNER JOIN products ON stock_items.product_id = products.product_id
INNER JOIN brands ON products.brand_id = brands.brand_id
INNER JOIN categories ON products.category_id = categories.category_id
WHERE orders.order_id = 2001
GROUP BY
products.product_id, products.product_name,
brands.brand_name, categories.category_name;This query retrieves the full product breakdown for order 2001 — including brand and category names, and the quantity of each product ordered. The COUNT(stock_items.stock_id) leverages the serialised stock model to calculate quantity by counting individual allocated stock records. This is an elegant multi-table JOIN across 5 tables.
Query 3: All Customers and Their Orders (Including Those With No Orders)
SELECT customers.customer_id, customers.customer_first_name, orders.order_id
FROM customers
LEFT JOIN orders ON customers.customer_id = orders.customer_id;A LEFT JOIN here ensures every customer appears in the results even those who have registered but never placed an order. This is useful for CRM purposes: identifying inactive accounts for re-engagement campaigns.
Key Design Strengths
Normalisation done right. The schema is well-normalised, with lookup tables for brands and categories, and no repeated data across records.
Constraint-driven data integrity. Business rules are enforced at the database level using CHECK, UNIQUE, NOT NULL, and appropriate foreign key actions — reducing reliance on application-level validation.
Serialised inventory model. Tracking individual stock items rather than aggregate counts enables precise order allocation, serialised tracking, and warehouse-level availability queries.
Geographically-aware delivery routing. The postcode_area_coverage table with a delivery_time attribute creates a foundation for intelligent order routing to the nearest warehouse.
Potential Improvements to Consider
While the schema is solid for a learning/portfolio project, a few enhancements would be worth considering for a production system:
Payment security: Raw card details should never be stored. Integrating a tokenised payment provider (Stripe, Braintree) and storing only a token would be the real-world approach.
Order items table: Currently, the link between an order and its products flows through stock_items.order_id. An explicit order_items junction table could make order-level aggregations cleaner.
Stock status as an ENUM: Using an integer for stock_status works, but a named ENUM or lookup table would improve readability and prevent invalid status codes.
Indexes: For a production database, adding indexes on frequently queried foreign keys (e.g., stock_items.product_id, orders.customer_id) would improve query performance at scale.
Timestamps with timezone: Using TIMESTAMP WITH TIME ZONE rather than plain TIMESTAMP would be important for a UK business handling orders across daylight saving time transitions.
Conclusion
This project is a well-structured, real-world SQL database that models the core logistics of an e-commerce fulfilment operation. It demonstrates solid understanding of relational database design including normalisation, referential integrity, many-to-many relationships, and constraint-based data validation.
The standout design choice is the postcode_area_coverage bridge table, which elegantly solves the geographic routing problem at the heart of fast delivery logistics. Combined with the serialised inventory model in stock_items, the schema creates a foundation on which powerful order routing and tracking logic can be built.
Whether you're learning SQL database design or looking for a reference architecture for an e-commerce backend, this project is worth exploring.