🔥 0
0
Lesson 2 of 8 20 min +200 XP

The Spring Boot E-Commerce API

Before ShopKart can have an AI agent, it needs something for the agent to act on: a real catalog, real orders, real endpoints. In this lesson you build the plain Spring Boot REST API. No AI yet - just clean, testable endpoints. Everything the agent does later (searching products, checking stock, placing orders) will be a thin wrapper over the code you write here.

Think of this lesson as laying the foundation. If the API is solid, the agent is easy. If the API is shaky, no amount of clever prompting will save you.

Project dependencies

Create a Spring Boot 3.x project (via [start.spring.io](https://start.spring.io)) targeting Java 17 or newer. For this lesson you only need the web starter. We will add the Spring AI dependencies in lesson 4.

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
</dependencies>

The domain model

Start with the Product. In modern Java 17+ this is a great fit for a record - immutable, concise, and it gives you equals, hashCode, and accessors for free.

package com.shopkart.model;

import java.math.BigDecimal;
import java.util.List;

public record Product(
        Long id,
        String name,
        String category,
        String brand,
        BigDecimal price,     // INR, always BigDecimal for money
        double rating,        // 0.0 - 5.0
        int stock,
        String description,
        List<String> tags
) {}

Next, Customer and the Order aggregate. An order holds a list of items, each pointing at a product id and a quantity.

package com.shopkart.model;

public record Customer(Long id, String name, String email, String city) {}
// OrderItem.java
package com.shopkart.model;

public record OrderItem(Long productId, int quantity) {}
// Order.java
package com.shopkart.model;

import java.math.BigDecimal;
import java.util.List;

public record Order(
        Long id,
        Long customerId,
        List<OrderItem> items,
        BigDecimal total,     // computed, in INR
        String status         // PLACED, SHIPPED, DELIVERED
) {}

Using BigDecimal for price and total is not optional in commerce code. Floating-point double cannot represent 0.10 exactly, so sums drift by fractions of a paisa and reconciliation reports stop balancing. BigDecimal keeps rupee maths exact.

The repository and seed data

For this course we use a simple in-memory repository so you can run everything with zero database setup. The interface is deliberately shaped like a Spring Data repository, so swapping in JPA later is a small change.

package com.shopkart.repository;

import com.shopkart.model.Product;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

@Repository
public class ProductRepository {

    private final Map<Long, Product> store = new ConcurrentHashMap<>();
    private final AtomicLong sequence = new AtomicLong();

    public ProductRepository() {
        seed("boAt Airdopes 141", "Audio", "boAt", "1299", 4.2, 58,
             "Wireless earbuds with 42-hour playback and low-latency mode",
             List.of("wireless", "bluetooth", "earbuds", "sports"));
        seed("Noise Buds VS104", "Audio", "Noise", "1099", 4.0, 34,
             "TWS earbuds with quad-mic ENC and fast charging",
             List.of("wireless", "bluetooth", "earbuds"));
        seed("Nike Revolution 7", "Footwear", "Nike", "3495", 4.5, 12,
             "Lightweight running shoes with soft foam cushioning",
             List.of("running", "sports", "shoes"));
        seed("Puma Softride", "Footwear", "Puma", "2999", 4.3, 0,
             "Everyday running shoes with breathable mesh upper",
             List.of("running", "sports", "shoes"));
        seed("Noise ColorFit Pulse 2", "Wearables", "Noise", "1499", 4.1, 41,
             "Smartwatch with 1.8 inch display and SpO2 tracking",
             List.of("smartwatch", "fitness", "wearable"));
        seed("boAt Rockerz 255 Pro", "Audio", "boAt", "1499", 4.4, 27,
             "Neckband with 40-hour playback and ASAP charge",
             List.of("wireless", "bluetooth", "neckband", "sports"));
    }

    private void seed(String name, String cat, String brand, String price,
                      double rating, int stock, String desc, List<String> tags) {
        long id = sequence.incrementAndGet();
        store.put(id, new Product(id, name, cat, brand,
                new BigDecimal(price), rating, stock, desc, tags));
    }

    public List<Product> findAll() {
        return new ArrayList<>(store.values());
    }

    public Optional<Product> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    public List<Product> search(String query, BigDecimal maxPrice) {
        String q = query == null ? "" : query.toLowerCase();
        return store.values().stream()
                .filter(p -> q.isBlank()
                        || p.name().toLowerCase().contains(q)
                        || p.category().toLowerCase().contains(q)
                        || p.tags().stream().anyMatch(t -> t.contains(q)))
                .filter(p -> maxPrice == null || p.price().compareTo(maxPrice) <= 0)
                .sorted(Comparator.comparingDouble(Product::rating).reversed())
                .toList();
    }

    public boolean decrementStock(Long id, int qty) {
        Product p = store.get(id);
        if (p == null || p.stock() < qty) return false;
        store.put(id, new Product(p.id(), p.name(), p.category(), p.brand(),
                p.price(), p.rating(), p.stock() - qty, p.description(), p.tags()));
        return true;
    }
}

That search method is doing double duty: it powers the storefront search bar today, and in lesson 5 the agent's searchProducts tool will call it directly. Build it once, use it twice.

The Product controller

Now expose the read endpoints. Standard Spring MVC - @RestController, constructor injection, and a 404 when a product id does not exist.

package com.shopkart.controller;

import com.shopkart.model.Product;
import com.shopkart.repository.ProductRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.util.List;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductRepository products;

    public ProductController(ProductRepository products) {
        this.products = products;
    }

    // GET /api/products?q=earbuds&maxPrice=2000
    @GetMapping
    public List<Product> list(
            @RequestParam(required = false) String q,
            @RequestParam(required = false) BigDecimal maxPrice) {
        if (q == null && maxPrice == null) {
            return products.findAll();
        }
        return products.search(q, maxPrice);
    }

    // GET /api/products/3
    @GetMapping("/{id}")
    public ResponseEntity<Product> byId(@PathVariable Long id) {
        return products.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

The Order controller

Placing an order is the first write in ShopKart, so it does real work: validate the request, look up each product, check stock, compute the total in BigDecimal, and decrement inventory. This service logic is exactly what the agent's placeOrder tool will reuse later.

package com.shopkart.service;

import com.shopkart.model.*;
import com.shopkart.repository.ProductRepository;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;

@Service
public class OrderService {

    private final ProductRepository products;
    private final Map<Long, Order> orders = new HashMap<>();
    private final AtomicLong sequence = new AtomicLong();

    public OrderService(ProductRepository products) {
        this.products = products;
    }

    public Order place(Long customerId, List<OrderItem> items) {
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("Order must contain at least one item");
        }
        BigDecimal total = BigDecimal.ZERO;
        for (OrderItem item : items) {
            Product p = products.findById(item.productId())
                    .orElseThrow(() -> new NoSuchElementException(
                            "Product not found: " + item.productId()));
            if (!products.decrementStock(p.id(), item.quantity())) {
                throw new IllegalStateException("Insufficient stock for " + p.name());
            }
            total = total.add(p.price().multiply(BigDecimal.valueOf(item.quantity())));
        }
        long id = sequence.incrementAndGet();
        Order order = new Order(id, customerId, items, total, "PLACED");
        orders.put(id, order);
        return order;
    }

    public Optional<Order> findById(Long id) {
        return Optional.ofNullable(orders.get(id));
    }
}
package com.shopkart.controller;

import com.shopkart.model.*;
import com.shopkart.service.OrderService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

record PlaceOrderRequest(Long customerId, List<OrderItem> items) {}

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orders;

    public OrderController(OrderService orders) {
        this.orders = orders;
    }

    // POST /api/orders
    @PostMapping
    public ResponseEntity<Order> place(@RequestBody PlaceOrderRequest request) {
        Order order = orders.place(request.customerId(), request.items());
        return ResponseEntity.status(201).body(order);
    }
}

Handling errors cleanly

If a customer orders an out-of-stock product, OrderService throws. Without handling, Spring returns a raw 500. A small @RestControllerAdvice turns those into clean, predictable JSON - which matters even more later, because the agent reads these error messages and relays them to the customer.

package com.shopkart.controller;

import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;

import java.util.*;

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
    public ResponseEntity<Map<String, String>> badRequest(RuntimeException ex) {
        return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage()));
    }

    @ExceptionHandler(NoSuchElementException.class)
    public ResponseEntity<Map<String, String>> notFound(RuntimeException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(Map.of("error", ex.getMessage()));
    }
}

The request flow

Here is what happens when the browser posts a new order, end to end through the layers you just built.

POST /api/orders - request flow OrderController parse JSON body OrderService validate + total (BigDecimal) ProductRepository findById + decrementStock 201 Created Order JSON status PLACED The same OrderService.place(...) is called by the agent's placeOrder tool in lesson 5.

Trying it out

Start the app with mvn spring-boot:run and hit it with curl:

# List everything
curl http://localhost:8080/api/products

# Search: audio products under 1300 INR
curl "http://localhost:8080/api/products?q=audio&maxPrice=1300"

# Place an order for 2 units of product 1
curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerId": 100, "items": [{"productId": 1, "quantity": 2}]}'

That last call returns a 201 with the computed total (1299 x 2 = 2598.00) and drops the stock of product 1 from 58 to 56.

On the job

Keep business logic in services, not controllers. When the agent arrives, its tools should call OrderService.place(...), not re-implement pricing and stock checks. If your logic lives in the controller, you either duplicate it for the agent or you cannot reuse it at all. Thin controllers, fat services - your future AI layer will thank you.

Key takeaways

  • Model the domain with immutable Java 17 record types and always use BigDecimal for rupee prices and totals.
  • Keep controllers thin and put validation, pricing, and stock logic in @Service classes so it can be reused by the agent later.
  • The ProductRepository.search(...) and OrderService.place(...) methods are the exact code the agent's tools will wrap in lesson 5.
  • A small @RestControllerAdvice produces clean error JSON - which the agent will later read and relay to customers.
  • You now have working GET /products, GET /products/{id}, and POST /orders endpoints.

Next up: React Storefront and Chat UI - build the product grid and a ChatWidget that will soon talk to your agent.