Tutorial 11: Build an e-commerce application
Objective: create a complete store — catalogue, product pages, a cart, checkout with server-side pricing and stock, accounts, order history and an admin — and understand how its pieces connect.
Prerequisites: Tutorials 5–10. This tutorial reads a large, finished project rather than typing it: the IWC store example, which ships with the CLI as a template.
Step 1: Create the store
iwc create my-store --template store
cd my-store
iwc validate✓ Created my-store from the store template — targets: web
Sign in as casey@lumen.test / casey-pass-123 (or admin@lumen.test / admin-pass-123).
1/4 files and references
✓ 47 files · 13 screens · 9 apis · 5 schemas
2/4 tests
✓ 42 test(s) passed
3/4 production build
✓ my-store 0.1.0 builds (13 pages, 9 apis)
4/4 security
! app.iwc: actions send notifications but no mail integration is installed, so production can't deliver them
✓ valid in 1.062sThe security stage flags a real gap before you deploy: the store sends order emails, so install a mail provider (iwc add @iwc/mail) before going live.
iwc dev --openStep 2: The map
| Route | Screen | Shows |
|---|---|---|
/ | ui/home.ui.iwc | Hero, features, live featured products, reviews carousel, FAQ |
/products | ui/products.ui.iwc | Category chips, ?category= filter, search, pagination |
/products/:slug | ui/product.ui.iwc | Product details, stock, add to cart, related products |
/cart | ui/cart.ui.iwc | Client-side cart with quantity controls |
/checkout | ui/checkout.ui.iwc | Sign-in required; shipping form; server-side checkout |
/account/orders | ui/orders.ui.iwc | Order history, owner-only |
/admin | ui/admin.ui.iwc | Stock chart, orders with "Mark shipped", catalogue |
Step 3: The product table
{
"name": "product",
"fields": {
"name": { "type": "string", "required": true, "maxLength": 120 },
"slug": { "type": "string", "required": true, "unique": true, "pattern": "^[a-z0-9-]+$" },
"description": { "type": "text", "maxLength": 4000 },
"price": { "type": "number", "required": true, "min": 0 },
"stock": { "type": "integer", "required": true, "min": 0, "default": 0 },
"image": { "type": "string", "maxLength": 300 },
"category": { "type": "relation", "to": "category.schema.iwc" },
"featured": { "type": "boolean", "default": false },
"active": { "type": "boolean", "default": true },
"costPrice": { "type": "number", "sensitive": true, "description": "never sent to browsers" }
},
"indexes": [["active", "featured"]],
"access": { "read": "public", "create": ["admin"], "update": ["admin"], "delete": ["admin"] },
"seed": [
{ "id": "p-arc", "name": "Arc Floor Lamp", "slug": "arc-floor-lamp", "description": "A sweeping brass arc over a walnut base.\nDimmable, warm 2700K.", "price": 289, "stock": 12, "image": "/assets/products/arc.svg", "category": "cat-lighting", "featured": true, "costPrice": 120 },
{ "id": "p-moon", "name": "Moon Pendant", "slug": "moon-pendant", "description": "Hand-blown opal glass that glows like a winter moon.", "price": 149, "stock": 30, "image": "/assets/products/moon.svg", "category": "cat-lighting", "featured": true, "costPrice": 51 },
{ "id": "p-stool", "name": "Oak Milking Stool", "slug": "oak-milking-stool", "description": "Three legs, one solid piece of oak, a lifetime of sitting.", "price": 95, "stock": 3, "image": "/assets/products/stool.svg", "category": "cat-furniture", "featured": true, "costPrice": 30 },
{ "id": "p-shelf", "name": "Floating Shelf", "slug": "floating-shelf", "description": "Hidden-bracket ash shelf, 90cm.", "price": 120, "stock": 18, "image": "/assets/products/shelf.svg", "category": "cat-furniture", "featured": false, "costPrice": 44 },
{ "id": "p-bowl", "name": "Turned Walnut Bowl", "slug": "turned-walnut-bowl", "description": "Lathe-turned and oiled by hand.", "price": 68, "stock": 25, "image": "/assets/products/bowl.svg", "category": "cat-objects", "featured": true, "costPrice": 20 },
{ "id": "p-candle", "name": "Beeswax Taper Set", "slug": "beeswax-taper-set", "description": "Six hand-dipped tapers.", "price": 24, "stock": 80, "image": "/assets/products/candle.svg", "category": "cat-objects", "featured": false, "costPrice": 6 },
{ "id": "p-desk", "name": "Task Desk Lamp", "slug": "task-desk-lamp", "description": "Spring-balanced arm, matte black.", "price": 179, "stock": 9, "image": "/assets/products/desk.svg", "category": "cat-lighting", "featured": false, "costPrice": 70 },
{ "id": "p-chandelier", "name": "Grand Atelier Chandelier", "slug": "grand-atelier-chandelier", "description": "Twelve arms of forged brass. Made to order.", "price": 6400, "stock": 2, "image": "/assets/products/chandelier.svg", "category": "cat-lighting", "featured": false, "costPrice": 2900 },
{ "id": "p-retired", "name": "Retired Sconce", "slug": "retired-sconce", "description": "No longer made.", "price": 80, "stock": 4, "image": "/assets/products/moon.svg", "category": "cat-lighting", "active": false, "costPrice": 30 }
]
}Step 4: The cart lives in the browser
{
"name": "cart",
"persist": "local",
"initial": { "items": [] },
"mutations": {
"add": { "op": "upsert", "path": "items", "key": "id", "count": "quantity" },
"change": { "op": "upsert", "path": "items", "key": "id", "count": "quantity" },
"remove": { "op": "remove", "path": "items", "key": "id" },
"clear": { "op": "clear", "path": "items" }
},
"derived": {
"count": { "sum": "items", "field": "quantity" },
"lines": { "count": "items" },
"total": { "sum": "items", "multiply": ["price", "quantity"] }
}
}upsert adds a product or raises its quantity; derived values (count, total) update everywhere they are bound, such as the header badge ("bind": "state.cart.count"). persist: "local" keeps the cart across visits. Add-to-cart buttons run "action": "state", "store": "cart", "mutation": "add".
Step 5: Checkout is server code
The checkout form sends the cart along with the address ("submit": {"include": ["STATE.cart.items"]}) to this API:
{
"name": "checkout",
"method": "POST",
"path": "/checkout",
"action": "checkout.action.iwc",
"request": {
"payload": {
"items": {
"type": "array", "required": true, "minItems": 1, "maxItems": 50,
"items": {
"type": "object",
"fields": {
"id": { "type": "string", "required": true },
"quantity": { "type": "integer", "required": true, "min": 1, "max": 20 },
"name": { "type": "string", "description": "display only; ignored for pricing" },
"price": { "type": "number", "description": "display only; prices are read from the database" },
"image": { "type": "string" },
"slug": { "type": "string", "description": "display only" }
}
}
},
"shipName": { "type": "string", "required": true, "maxLength": 120 },
"address": { "type": "string", "required": true, "maxLength": 200 },
"city": { "type": "string", "required": true, "maxLength": 80 },
"postalCode": { "type": "string", "required": true, "pattern": "^[A-Za-z0-9 -]{3,12}$" }
}
},
"response": { "status": 200, "fields": { "orderId": "string", "total": "number", "status": "string" } },
"security": { "auth": true, "rateLimit": "10/1m" }
}Its action never trusts the browser's prices:
{
"name": "Checkout",
"description": "Prices come from the database, never the request. Stock is re-checked inside the transaction and decremented atomically; a declined payment rolls everything back.",
"auth": "required",
"transaction": true,
"input": {
"items": {
"type": "array", "required": true, "minItems": 1, "maxItems": 50,
"items": { "type": "object", "fields": {
"id": { "type": "string", "required": true },
"quantity": { "type": "integer", "required": true, "min": 1, "max": 20 },
"name": { "type": "string" }, "price": { "type": "number" }, "image": { "type": "string" }, "slug": { "type": "string" }
} }
},
"shipName": { "type": "string", "required": true },
"address": { "type": "string", "required": true },
"city": { "type": "string", "required": true },
"postalCode": { "type": "string", "required": true }
},
"steps": [
{ "operation": "validate", "id": "validate-cart", "condition": "len(input.items) > 0", "error": "Your cart is empty" },
{ "operation": "set", "as": "lines", "value": [] },
{
"operation": "forEach", "id": "check-inventory", "items": "input.items", "as": "line",
"steps": [
{ "operation": "find", "resource": "product.schema.iwc", "where": { "id": "=line.id", "active": true }, "as": "product", "required": true, "status": 404, "error": "=concat('A product in your cart is no longer available')" },
{ "operation": "require", "condition": "product.stock >= line.quantity", "status": 409, "error": "=concat('Only ', product.stock, ' left of ', product.name)" },
{ "operation": "set", "as": "lines", "append": { "productId": "=product.id", "name": "=product.name", "unitPrice": "=product.price", "quantity": "=line.quantity" } }
]
},
{ "operation": "set", "id": "calculate-total", "as": "subtotal", "value": "round(sum(lines, unitPrice * quantity), 2)" },
{ "operation": "set", "as": "shipping", "value": "if(subtotal >= 150, 0, 12)" },
{ "operation": "set", "as": "total", "value": "round(subtotal + shipping, 2)" },
{
"operation": "create", "id": "create-order", "resource": "order.schema.iwc", "as": "order",
"values": {
"items": "=lines", "subtotal": "=subtotal", "shipping": "=shipping", "total": "=total", "status": "pending",
"shipName": "=input.shipName", "address": "=input.address", "city": "=input.city", "postalCode": "=input.postalCode"
}
},
{ "operation": "call", "id": "process-payment", "service": "payment.service.iwc", "method": "charge", "input": { "amount": "=total", "currency": "USD", "reference": "=order.id" }, "as": "payment", "status": 402, "error": "Your payment was declined; you have not been charged" },
{
"operation": "forEach", "id": "update-inventory", "items": "lines", "as": "l",
"steps": [
{ "operation": "update", "resource": "product.schema.iwc", "where": { "id": "=l.productId", "stock": { "gte": "=l.quantity" } }, "increment": { "stock": "=-l.quantity" }, "expect": 1, "error": "Stock changed while you were checking out; please try again" }
]
},
{ "operation": "transition", "workflow": "order.workflow.iwc", "transition": "pay", "value": "=order.id" },
{ "operation": "update", "resource": "order.schema.iwc", "where": { "id": "=order.id" }, "values": { "paymentRef": "=payment.id" } },
{ "operation": "emit", "event": "order-placed.event.iwc", "payload": { "orderId": "=order.id", "email": "=user.email", "total": "=total" } },
{ "operation": "return", "value": { "orderId": "=order.id", "total": "=total", "status": "paid" } }
]
}Read it top to bottom:
- For each line,
findthe product (it must still be active) andrequireenough stock. - Compute the subtotal, shipping and total from database prices.
createthe order, thencallthe payment service. A declined payment fails the step with 402, and because the action is"transaction": true, the order is rolled back.- Decrement stock with a guarded
update:where stock >= quantityplusexpect: 1, so two customers can never buy the last item twice. - Move the order through the workflow, store the payment reference,
emitan event and return.
Step 6: Workflow, event and job
{
"name": "OrderFlow",
"resource": "order.schema.iwc",
"field": "status",
"initial": "pending",
"states": ["pending", "paid", "shipped", "delivered", "cancelled"],
"transitions": [
{ "name": "pay", "from": ["pending"], "to": "paid" },
{ "name": "ship", "from": ["paid"], "to": "shipped", "roles": ["admin"], "action": "notify-shipped.action.iwc" },
{ "name": "deliver", "from": ["shipped"], "to": "delivered", "roles": ["admin"] },
{ "name": "cancel", "from": ["pending", "paid"], "to": "cancelled", "roles": ["admin"] }
]
}{
"name": "OrderPlaced",
"payload": {
"orderId": { "type": "string", "required": true },
"email": { "type": "email", "required": true },
"total": { "type": "number", "required": true }
},
"handlers": [{ "job": "order-email.job.iwc" }],
"broadcast": true
}{
"name": "OrderEmail",
"action": "send-order-email.action.iwc",
"retries": 3,
"timeout": "30s"
}The ship transition is admin-only and exposed by an API (api/ship-order.api.iwc), which is what the admin's "Mark shipped" button calls. The OrderPlaced event queues the email job inside the checkout transaction, so a failed checkout never sends an email.
Step 7: Payments
The store uses a simulated payment service (orders over $5,000 are declined so you can see the failure path):
{
"name": "Payments",
"provider": "simulated",
"methods": {
"charge": {
"respond": { "id": "=concat('pay_', input.reference)", "status": "succeeded", "amount": "=input.amount", "currency": "=input.currency" },
"fail": "input.amount > 5000"
}
}
}Production refuses to start with it. Replace it with a real provider as shown in Tutorial 12.
Step 8: Try it
- Add products to the cart and open
/cart; change quantities. - Check out: you are sent to sign in and brought back. Use
casey@lumen.test/casey-pass-123. - Open
/account/ordersto see the order. - Sign in as
admin@lumen.test/admin-pass-123, open/admin, and mark the order shipped.
Make it yours
- Edit
app.iwctheme tokens andstyles/for your brand. - Replace the seed products in
database/product.schema.iwcor add them through the admin form. - Install real providers:
iwc add @iwc/payments,iwc add @iwc/mail,iwc add @iwc/shipping.
Troubleshooting
| Problem | Fix |
|---|---|
| "Only 2 left of …" | The checkout's stock check; reduce the quantity. |
| "Your payment was declined" | The simulated service declines totals over $5,000. |
| Changes to seed products don't appear | Seeds go into empty tables only; delete .iwc/ to reset. |
Next: Implement payments.
Source: apps/marketing/content/docs/tutorials/ecommerce.md