Spring Boot REST API for a webstore order management system.
Built with Java 17, Spring Boot 3.2, Spring Data JPA, and MariaDB 11.5.
- Java 17+
- Maven 3.8+
- MariaDB 11.5
Edit src/main/resources/application.properties:
spring.datasource.url=jdbc:mariadb://localhost:3306/webstore
spring.datasource.username=your_username
spring.datasource.password=your_passwordThe API listens on http://localhost:8080.
List all customers. Optional search param filters by first and last name.
Query params: search (optional)
Index used:
idx_customer_lastname— the JPQL query performs aLIKEmatch onlast_nameandfirst_name. MariaDB uses the index onlast_nameto narrow the scan before evaluating thefirst_namecondition, avoiding a full table scan on large customer datasets.
Response: 200 OK — array of:
{
"id": integer,
"firstName": string,
"lastName": string,
"email": string,
"phone": string | null
}Get a single customer with their addresses.
Response: 200 OK
{
"id": integer,
"firstName": string,
"lastName": string,
"email": string,
"phone": string | null,
"addresses": [
{
"id": integer,
"streetAddress": string,
"postalCode": string | null,
"city": string,
"country": string | null
}
]
}Create a new customer.
Index used:
idx_customer_email(unique) — before inserting, the service checks for a duplicate email withexistsByEmail(). This lookup hits the unique index oncustomers.emaildirectly instead of scanning the whole table.
Request body:
{
"firstName": string,
"lastName": string,
"email": string,
"phone": string | null
}Response: 201 Created
{
"id": integer,
"firstName": string,
"lastName": string,
"email": string,
"phone": string | null
}Update a customer.
Index used:
idx_customer_email(unique) — same duplicate email check as on create, using the index for an O(log n) lookup.
Request body: same as POST /api/customers
Response: 200 OK — same as POST response
Delete a customer (cascades to addresses).
Response: 204 No Content
List all addresses for a customer.
Index used:
fk_customeraddress_customer— the query filterscustomeraddressesbycustomer_id, which is a foreign key index. MariaDB uses it to retrieve only the rows belonging to this customer without scanning the full addresses table.
Response: 200 OK — array of:
{
"id": integer,
"streetAddress": string,
"postalCode": string | null,
"city": string,
"country": string | null
}Add an address to a customer.
Request body:
{
"streetAddress": string,
"postalCode": string | null,
"city": string,
"country": string | null
}Response: 201 Created
{
"id": integer,
"streetAddress": string,
"postalCode": string | null,
"city": string,
"country": string | null
}Update a specific address.
Request body: same as POST /api/customers/{id}/addresses
Response: 200 OK — same as POST address response
Delete an address.
Response: 204 No Content
List all suppliers. Optional search param filters by name.
Query params: search (optional)
Response: 200 OK — array of:
{
"id": integer,
"name": string,
"contactName": string | null,
"phone": string | null,
"email": string | null
}Get a supplier with their addresses.
Index used:
fk_supplieraddress_supplier— addresses are fetched bysupplier_idforeign key index, same pattern as customer addresses.
Response: 200 OK
{
"id": integer,
"name": string,
"contactName": string | null,
"phone": string | null,
"email": string | null,
"addresses": [
{
"id": integer,
"streetAddress": string,
"postalCode": string | null,
"city": string,
"country": string | null
}
]
}Create a supplier.
Request body:
{
"name": string,
"contactName": string | null,
"phone": string | null,
"email": string | null
}Response: 201 Created
{
"id": integer,
"name": string,
"contactName": string | null,
"phone": string | null,
"email": string | null
}Update a supplier.
Request body: same as POST /api/suppliers
Response: 200 OK — same as POST response
Delete a supplier.
Response: 204 No Content
List supplier addresses.
Index used:
fk_supplieraddress_supplier— filters bysupplier_idusing the foreign key index.
Response: 200 OK — array of:
{
"id": integer,
"streetAddress": string,
"postalCode": string | null,
"city": string,
"country": string | null
}Add an address to a supplier.
Request body: same as POST /api/customers/{id}/addresses
Response: 201 Created — same structure as customer address response
Delete a supplier address.
Response: 204 No Content
List products. Supports multiple optional filters (only one applied at a time):
| Param | Type | Description |
|---|---|---|
search |
string | Search by product name |
categoryId |
integer | Filter by category |
supplierId |
integer | Filter by supplier |
minPrice |
decimal | Lower price bound |
maxPrice |
decimal | Upper price bound |
Index used:
idx_product_name— whensearchis provided, theLIKEquery onproducts.namebenefits from the index, especially for prefix-style searches.
Index used:fk_product_category— when filtering bycategoryId, the foreign key index oncategory_idis used for a direct index scan instead of a full table scan.
Index used:fk_product_supplier— same as above forsupplierIdfiltering.
Index used:idx_product_price— theBETWEENquery for price range filtering uses the index onpriceto efficiently locate the matching range without scanning all rows.
Response: 200 OK — array of:
{
"id": integer,
"name": string,
"description": string | null,
"price": decimal,
"stockQuantity": integer,
"categoryName": string | null,
"supplierName": string | null,
"availability": "IN_STOCK" | "LOW_STOCK" | "OUT_OF_STOCK"
}availability is LOW_STOCK when stockQuantity < 10, OUT_OF_STOCK when stockQuantity = 0.
Get a single product.
Response: 200 OK — same as product list item
Products with stock below the threshold (default 10).
Index used:
idx_product_stock— theWHERE stock_quantity < :thresholdcondition uses the index onstock_quantityto scan only the relevant portion of the index rather than the entire products table.
Response: 200 OK — array of product objects
Products with zero stock.
Index used:
idx_product_stock— equality lookup onstock_quantity = 0uses the same index as the low-stock query.
Response: 200 OK — array of product objects
Create a product.
Request body:
{
"name": string,
"description": string | null,
"price": decimal,
"stockQuantity": integer,
"categoryId": integer | null,
"supplierId": integer | null
}Response: 201 Created — product object
Update a product.
Request body: same as POST /api/products
Response: 200 OK — product object
Update only the stock quantity.
Request body:
{ "stockQuantity": integer }Response: 200 OK — product object
Delete a product.
Response: 204 No Content
List all product categories.
Response: 200 OK — array of:
{
"id": integer,
"name": string,
"description": string | null
}Create a category.
Request body:
{
"name": string,
"description": string | null
}Response: 201 Created — category object
Update a category.
Request body: same as POST /api/products/categories
Response: 200 OK — category object
Delete a category.
Response: 204 No Content
List orders. Supports filters (only one applied at a time):
| Param | Type | Description |
|---|---|---|
status |
string | Filter by status: NEW, PROCESSING, SHIPPED, DELIVERED, CANCELLED |
customerId |
integer | Filter by customer |
from |
ISO datetime | Start of date range |
to |
ISO datetime | End of date range |
Index used:
idx_order_status— filtering bystatushits the index directly, which is especially effective given the low cardinality of the status column. MariaDB can resolve the full result set from the index alone.
Index used:fk_order_customer— filtering bycustomerIduses the foreign key index oncustomer_id, returning only that customer's orders without touching unrelated rows.
Index used:idx_order_date— theBETWEENdate range query uses the index onorder_dateto efficiently scan the relevant time window.
Response: 200 OK — array of:
{
"id": integer,
"customerId": integer,
"customerName": string,
"orderDate": datetime,
"deliveryDate": datetime | null,
"status": string,
"totalAmount": decimal,
"items": [
{
"productId": integer,
"productName": string,
"quantity": integer,
"unitPrice": decimal,
"subtotal": decimal
}
]
}Get a single order with full item details.
Uses a JPQL
JOIN FETCHquery that loads the order, customer, items, and products in a single SQL statement, avoiding the N+1 problem that would otherwise occur when iterating over lazy-loaded collections.
Response: 200 OK — order object (same structure as list item)
Place a new order. Runs in a single transaction — stock is checked and decremented atomically via DB triggers.
Request body:
{
"customerId": integer,
"shippingAddressId": integer | null,
"items": [
{
"productId": integer,
"quantity": integer
}
]
}Response: 201 Created — order object
Errors:
404if customer, address, or product not found409 Conflictif stock is insufficient (raised by DB trigger)400if address doesn't belong to the customer
Update order status. Enforces valid state transitions:
NEW → PROCESSING → SHIPPED → DELIVERED
↓ ↓
CANCELLED CANCELLED
Request body:
{ "status": string }Response: 200 OK — order object
Setting status to DELIVERED automatically records deliveryDate. The DB trigger trg_order_status_change records every transition in order_status_history.
Cancel an order. Cannot cancel a DELIVERED order.
Response: 204 No Content
Get the full status change history for an order (populated by DB triggers).
Index used:
idx_status_history_order— the history table can grow large over time. The index onorder_idmeans this query retrieves only the rows for the given order directly, without scanning the entire history table.
Response: 200 OK — array of:
{
"id": integer,
"orderId": integer,
"oldStatus": string | null,
"newStatus": string,
"changedAt": datetime
}Aggregated order statistics per customer.
Index used:
fk_order_customer— for each customer the service callsfindByCustomerId(), which filtersordersbycustomer_idusing the foreign key index. Without this index the query would require a full scan of the orders table per customer.
Response: 200 OK — array of:
{
"customerId": integer,
"customerName": string,
"email": string,
"totalOrders": long,
"totalSpent": decimal,
"lastOrderDate": datetime | null
}