Based on: Merchandise Store hands-on tutorial by Anerud
- What is Spring Boot?
- Problem Statement & System Design
- Database Design (ER Diagram)
- API Design
- Project Setup
- Project Structure Explained
- Configuration (application.yml + .env)
- Layer Architecture (MVC)
- Entities Layer
- Repository Layer
- Service Layer
- Controller Layer
- DTO Layer
- Order Flow — Full Implementation
- Swagger / OpenAPI Documentation
- Testing with an API Client
- Key Concepts Glossary
- Assignments / Take-Home Tasks
- An open-source Java framework that provides ready-made solutions for common developer problems: DB connectivity, REST APIs, authentication, messaging, etc.
- Analogy: like frozen/ready-made food — someone already did the cooking (DB drivers, ORM, auth), you just configure and use it.
- Built on top of Spring Framework.
- Takes an opinionated approach — sensible defaults out of the box, so you get up and running faster.
- Analogy: if Spring Framework is the ingredients + recipe, Spring Boot is the meal that just needs reheating.
- Used by Netflix, Walmart, Amazon, Google, and many fintech companies.
- Handles: microservices, web apps, cloud, serverless, generative AI backends.
- Developers focus on business logic, not boilerplate infrastructure code.
An influencer wants a merchandise store to sell items like t-shirts, mugs, and stickers.
| Question | Why it matters |
|---|---|
| What is the price of an item? | Core product info |
| When was an order placed? | Order tracking |
| How many products are left in stock? | Inventory management |
| What items are in a particular order? | Dispute resolution / refunds |
As a store owner (creator):
- Create a product
- Read a product / all products
- Update a product
- Delete a product
As a customer:
- Read all products
- Read a single product
- Place an order
- View order history
Two main things the entire system revolves around:
- Product
- Order
| Column | Type | Notes |
|---|---|---|
| id | Long (PK) | Auto-generated, primary key |
| name | String | NOT NULL |
| description | String | Nullable |
| category | String | Nullable |
| price | Decimal | NOT NULL, must be > 0 |
| stock_quantity | Integer | NOT NULL, must be >= 0 |
| created_at | Timestamp | Auto-set on insert |
| updated_at | Timestamp | Auto-set on update |
| Column | Type | Notes |
|---|---|---|
| id | Long (PK) | Auto-generated |
| customer_name | String | NOT NULL |
| customer_email | String | NOT NULL |
| status | String | e.g. "confirmed" |
| total_amount | Decimal | Computed total |
| created_at | Timestamp | Auto-set on insert |
| Column | Type | Notes |
|---|---|---|
| id | Long (PK) | Auto-generated |
| order_id | FK → Orders | NOT NULL |
| product_id | FK → Products | NOT NULL |
| quantity | Integer | NOT NULL |
| price_at_purchase | Decimal | Snapshot of price at time of order |
Why price_at_purchase? Product prices change over time (sales, demand). This field freezes the price at the moment the order was placed — essential for accurate revenue analytics.
Orders 1 ──────< OrderItems >────── Products
(one-to-many) (many-to-one)
- One Order → Many Order Items
- One Product → Many Order Items (product can appear in many orders until stock runs out)
| Method | Endpoint | Action |
|---|---|---|
POST |
/api/products |
Create a product |
PUT |
/api/products/{id} |
Update a product |
GET |
/api/products |
Get all products |
GET |
/api/products/{id} |
Get a single product |
DELETE |
/api/products/{id} |
Delete a product |
| Method | Endpoint | Action |
|---|---|---|
POST |
/api/orders |
Place an order |
GET |
/api/orders/{id} |
Get a single order |
GET |
/api/orders |
Get all orders |
- IntelliJ IDEA (Community edition is free)
- JDK (install via IntelliJ if not present)
- Maven (bundled with the project as
mvnw) - Supabase (or Docker + local Postgres) for the database
- New Project → Spring Boot
- Language: Java
- Build tool: Maven
- Group ID: reverse domain (e.g.
com.yourname) - Configuration format: YML (preferred over
.properties) - Java version: latest stable
| Dependency | Purpose |
|---|---|
Spring Web |
Enables REST API creation |
Spring Data JPA |
ORM layer (Hibernate) — maps Java classes to DB tables |
PostgreSQL Driver |
Connects Java app to Postgres DB |
Lombok |
Auto-generates getters, setters, constructors (reduces boilerplate) |
SpringDoc OpenAPI |
Auto-generates Swagger documentation |
Search Maven Repository for any package, copy the XML snippet, and paste it inside <dependencies> in pom.xml. Then sync Maven (IntelliJ will prompt you).
creator-store/
├── .idea/ ← IntelliJ IDE config (don't touch)
├── .mvn/ ← Maven wrapper config
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com.yourname.creatorstore/
│ │ │ ├── CreatorStoreApplication.java ← Main class (entry point)
│ │ │ ├── entities/ ← DB table models
│ │ │ ├── repositories/ ← DB access interfaces
│ │ │ ├── services/ ← Business logic
│ │ │ ├── controllers/ ← REST API endpoints
│ │ │ └── dto/ ← Data Transfer Objects
│ │ └── resources/
│ │ └── application.yml ← App configuration
│ └── test/ ← Unit tests
├── .env ← Secret credentials (NOT committed to git)
├── .gitignore
└── pom.xml ← Dependencies (like package.json for Java)
| File/Folder | JavaScript Equivalent |
|---|---|
pom.xml |
package.json |
application.yml |
.env / config files |
| Maven Registry | npm registry (npmjs.com) |
target/ folder |
dist/ or build/ folder |
spring:
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
jpa:
hibernate:
ddl-auto: update # Auto-creates/updates tables from entity classes
show-sql: true # Logs SQL queries (useful for debugging)
properties:
hibernate:
format_sql: true # Makes logged SQL human-readable
ddl-auto: update— Hibernate reads your Java entity classes and automatically creates or alters the database tables to match. You never write raw SQLCREATE TABLEcommands yourself.
DATABASE_URL=jdbc:postgresql://your-db-host:5432/postgres?...
DATABASE_USERNAME=your_username
DATABASE_PASSWORD=your_passwordAdd the env-java dependency from Maven Repository, then in CreatorStoreApplication.java:
Dotenv env = Dotenv.configure().ignoreIfMissing().load();
env.entries().forEach(e -> System.setProperty(e.getKey(), e.getValue()));This loads your .env file and makes values available to application.yml via ${KEY_NAME} syntax.
- Create project on supabase.com
- Go to Connect → Session Pooler → JDBC (not direct connection)
- Copy the JDBC URL, username, and password into your
.env
Spring Boot follows a clean 4-layer architecture. Each layer has a single responsibility.
Client (Postman / Frontend / Mobile)
│ HTTP Request
▼
┌─────────────────────┐
│ CONTROLLER LAYER │ ← Receives requests, routes to service, returns response
└─────────┬───────────┘
│ calls
┌─────────▼───────────┐
│ SERVICE LAYER │ ← Business logic lives here
└─────────┬───────────┘
│ calls
┌─────────▼───────────┐
│ REPOSITORY LAYER │ ← Talks to the database (CRUD)
└─────────┬───────────┘
│ maps to
┌─────────▼───────────┐
│ ENTITY LAYER │ ← Java classes that represent DB tables
└─────────────────────┘
SOLID Principle in action: The controller doesn't hold business logic. It only maps endpoints and delegates work to the service. This keeps things maintainable and testable.
Entities are Java classes annotated to tell Hibernate: "Create a database table from this class."
@Entity
@Table(name = "products")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
@NotBlank(message = "Product name is required")
private String name;
private String description; // nullable
private String category; // nullable
@Column(nullable = false)
@NotNull(message = "Price is required")
@DecimalMin(value = "0.0", inclusive = false, message = "Price must be greater than zero")
private BigDecimal price;
@Column(name = "stock_quantity", nullable = false)
@NotNull(message = "Stock quantity is required")
@Min(value = 0, message = "Stock cannot be less than zero")
private Integer stockQuantity;
@OneToMany(mappedBy = "product")
@JsonIgnore // prevents infinite loop when serializing
private List<OrderItem> orderItems;
}@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "customer_name", nullable = false)
private String customerName;
@Column(name = "customer_email", nullable = false)
private String customerEmail;
@Column(nullable = false)
private String status;
@Column(name = "total_price", nullable = false)
private BigDecimal totalPrice;
@Column(name = "created_at")
private LocalDateTime createdAt;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
@JsonManagedReference
private List<OrderItem> orderItems;
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();
}
}@Entity
@Table(name = "order_items")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "order_id", nullable = false)
@JsonBackReference
private Order order;
@ManyToOne
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(nullable = false)
private Integer quantity;
@Column(name = "price_at_purchase", nullable = false)
private BigDecimal priceAtPurchase;
}| Annotation | Source | Purpose |
|---|---|---|
@Entity |
JPA | Marks class as a DB table |
@Table(name="...") |
JPA | Sets the exact table name |
@Id |
JPA | Marks the primary key field |
@GeneratedValue |
JPA | Auto-increments the ID |
@Column(nullable=false) |
JPA | Adds NOT NULL constraint |
@OneToMany |
JPA | Defines 1-to-many relationship |
@ManyToOne |
JPA | Defines many-to-1 relationship |
@JoinColumn |
JPA | Specifies the foreign key column |
@PrePersist |
JPA | Method runs before INSERT |
@Getter @Setter |
Lombok | Auto-generates getters/setters |
@Builder |
Lombok | Enables builder design pattern |
@NoArgsConstructor |
Lombok | Generates no-arg constructor |
@AllArgsConstructor |
Lombok | Generates all-args constructor |
@NotBlank |
Validation | Field cannot be blank |
@NotNull |
Validation | Field cannot be null |
@DecimalMin |
Validation | Minimum decimal value |
@Min |
Validation | Minimum integer value |
@JsonIgnore |
Jackson | Excludes field from JSON output |
@JsonManagedReference |
Jackson | Handles bidirectional JSON serialization (parent side) |
@JsonBackReference |
Jackson | Handles bidirectional JSON serialization (child side) |
Repositories are interfaces (not classes) that extend JpaRepository. Hibernate writes the actual implementation — you get CRUD for free.
// ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
// JpaRepository<EntityType, IDType>
// Built-in methods available automatically:
// .save(entity) → INSERT or UPDATE
// .findById(id) → SELECT by primary key
// .findAll() → SELECT all rows
// .deleteById(id) → DELETE by primary key
}
// OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long> { }
// OrderItemRepository.java
public interface OrderItemRepository extends JpaRepository<OrderItem, Long> { }You define the interface. JPA + Hibernate writes the SQL. You never touch SQL for standard CRUD.
Services contain all business logic. They talk to repositories for persistence.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
// CREATE
public Product createProduct(Product product) {
return productRepository.save(product);
}
// UPDATE
public Product updateProduct(Long id, Product product) {
Product existing = productRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Product not found with ID: " + id));
existing.setName(product.getName());
existing.setDescription(product.getDescription());
existing.setCategory(product.getCategory());
existing.setPrice(product.getPrice());
existing.setStockQuantity(product.getStockQuantity());
return productRepository.save(existing);
}
// READ ALL
public List<Product> getProducts() {
return productRepository.findAll();
}
// READ ONE
public Product getProductById(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Product not found with ID: " + id));
}
// DELETE
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
}Controllers expose REST endpoints. They only receive requests, validate, and delegate to the service.
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@PostMapping
public Product createProduct(@Valid @RequestBody Product product) {
return productService.createProduct(product);
}
@PutMapping("/{id}")
public Product updateProduct(@PathVariable Long id,
@Valid @RequestBody Product product) {
return productService.updateProduct(id, product);
}
@GetMapping
public List<Product> getProducts() {
return productService.getProducts();
}
@GetMapping("/{id}")
public Product getProductById(@PathVariable Long id) {
return productService.getProductById(id);
}
@DeleteMapping("/{id}")
public void deleteProduct(@PathVariable Long id) {
productService.deleteProduct(id);
}
}| Annotation | Purpose |
|---|---|
@RestController |
Marks class as a REST API controller |
@RequestMapping("/path") |
Base path for all endpoints in class |
@PostMapping |
Maps HTTP POST |
@GetMapping |
Maps HTTP GET |
@PutMapping("/{id}") |
Maps HTTP PUT with path variable |
@DeleteMapping("/{id}") |
Maps HTTP DELETE with path variable |
@PathVariable |
Extracts {id} from URL path |
@RequestBody |
Reads JSON body from request |
@Valid |
Triggers field-level validation annotations |
DTOs (Data Transfer Objects) are simplified classes used to accept input from clients. They don't have DB relationships — just the fields you need.
When creating an order, you don't want the client to send full relational objects. You want a clean, flat input structure.
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class OrderItemRequest {
@NotNull(message = "Product ID is required")
private Long productId;
@NotNull(message = "Quantity is required")
@Min(value = 1, message = "Quantity must be at least one")
private Integer quantity;
}@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class OrderRequest {
@NotBlank(message = "Customer name is required")
private String customerName;
@NotBlank(message = "Customer email is required")
@Email(message = "Enter a valid email")
private String customerEmail;
@Valid
@NotEmpty(message = "Order must contain at least one item")
private List<OrderItemRequest> items;
}@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
@Transactional
public Order createOrder(OrderRequest orderRequest) {
Order order = new Order();
order.setCustomerName(orderRequest.getCustomerName());
order.setCustomerEmail(orderRequest.getCustomerEmail());
order.setStatus("confirmed");
List<OrderItem> orderItems = new ArrayList<>();
BigDecimal totalPrice = BigDecimal.ZERO;
for (OrderItemRequest itemRequest : orderRequest.getItems()) {
// 1. Check product exists
Product product = productRepository.findById(itemRequest.getProductId())
.orElseThrow(() -> new RuntimeException(
"Product not found with ID: " + itemRequest.getProductId()));
// 2. Check stock availability
if (product.getStockQuantity() < itemRequest.getQuantity()) {
throw new RuntimeException("Not enough stock for: " + product.getName());
}
// 3. Calculate price
BigDecimal priceOfItem = product.getPrice()
.multiply(BigDecimal.valueOf(itemRequest.getQuantity()));
totalPrice = totalPrice.add(priceOfItem);
// 4. Deduct stock
product.setStockQuantity(product.getStockQuantity() - itemRequest.getQuantity());
productRepository.save(product);
// 5. Build order item (captures price snapshot)
OrderItem orderItem = OrderItem.builder()
.order(order)
.product(product)
.quantity(itemRequest.getQuantity())
.priceAtPurchase(product.getPrice())
.build();
orderItems.add(orderItem);
}
order.setOrderItems(orderItems);
order.setTotalPrice(totalPrice);
return orderRepository.save(order);
}
}An order creation touches 3 tables (orders, order_items, products). If anything fails midway, @Transactional rolls back all changes, keeping the database consistent. Without it, you could deduct stock but fail to save the order.
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping
public Order createOrder(@Valid @RequestBody OrderRequest orderRequest) {
return orderService.createOrder(orderRequest);
}
// Assignments: implement these two below ↓
@GetMapping
public List<Order> getOrders() {
// TODO: implement in service
return null;
}
@GetMapping("/{id}")
public Order getOrderById(@PathVariable Long id) {
// TODO: implement in service
return null;
}
}Auto-generated interactive API documentation. Instead of manually maintaining API docs, Spring Boot generates them from your code.
Run the app, then visit:
http://localhost:8080/swagger-ui/index.html
- All controllers and endpoints listed automatically
- "Try it out" button to test endpoints directly from the browser
- Request body schemas shown automatically — no guessing about JSON format
- Useful for sharing with frontend developers
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.x.x</version>
</dependency>No extra configuration required — it works out of the box.
Use Requestly, Postman, or any HTTP client to test your endpoints.
POST http://localhost:8080/api/products
Content-Type: application/json
{
"name": "T-Shirt",
"category": "Apparel",
"price": 199.00,
"stockQuantity": 100
}
POST http://localhost:8080/api/orders
Content-Type: application/json
{
"customerName": "Dedar",
"customerEmail": "dedar@example.com",
"items": [
{
"productId": 1,
"quantity": 5
}
]
}
- Validates input fields
- Finds the product in DB — throws error if missing
- Checks stock availability — throws error if insufficient
- Calculates total price (
price × quantity) - Deducts quantity from product stock
- Creates
OrderItemrecord with price snapshot - Saves the full
Orderwith all items in a single transaction - Returns the saved order object
| Term | Explanation |
|---|---|
| ORM | Object-Relational Mapping — maps Java classes to DB tables automatically |
| Hibernate | The ORM library used by Spring Boot (implements JPA) |
| JPA | Jakarta Persistence API — the standard interface Hibernate implements |
| DDL | Data Definition Language — SQL for CREATE TABLE, ALTER TABLE |
| DML | Data Manipulation Language — SQL for INSERT, UPDATE, DELETE |
| Dependency Injection | Spring automatically creates and provides objects where needed (via @RequiredArgsConstructor + private final) |
| Bean | A Spring-managed object. @Service, @Repository, @RestController all create beans |
| Transaction | A group of DB operations that all succeed or all roll back together |
| DTO | Data Transfer Object — simplified class for accepting/returning API data |
| Junction Table | A table that connects two other tables in a many-to-many relationship |
| Builder Pattern | A design pattern for constructing objects field by field (via @Builder) |
| Lombok | Library that auto-generates getters, setters, constructors at compile time |
| Swagger/OpenAPI | Standard for documenting REST APIs; SpringDoc generates it automatically |
| JDBC | Java Database Connectivity — the protocol Java uses to talk to databases |
| Path Variable | A value embedded in the URL: /products/{id} |
| Request Body | JSON payload sent in the HTTP request body (POST/PUT) |
-
Implement
getOrders()— Return all orders from the DB viaorderRepository.findAll() -
Implement
getOrderById(Long id)— Return a single order, throwRuntimeExceptionif not found -
Add
@PrePersistto other entities — AddcreatedAtauto-population toProductandOrderItemas well -
Add extra validations to Order entity — Fields like status, total price should have proper validation annotations
-
Add error handling — Create a global
@ControllerAdviceclass to return clean error messages instead of stack traces -
Add
updatedAtfield — Use@PreUpdateannotation (works like@PrePersistbut fires on UPDATE) -
Add a search endpoint —
GET /api/products?category=Apparelusing a custom JPA query method -
Add pagination — Replace
findAll()withfindAll(Pageable pageable)for large product lists
POST /api/orders
│
▼
OrderController.createOrder(@Valid @RequestBody OrderRequest)
│ delegates to
▼
OrderService.createOrder(orderRequest) ← @Transactional
│ checks stock, calculates price, builds entities
▼
ProductRepository.findById() ← reads from DB
ProductRepository.save() ← updates stock in DB
OrderRepository.save() ← writes order + items to DB
│
▼
Returns Order object → JSON response to client
Built with Spring Boot 4.x · Hibernate · PostgreSQL (Supabase) · Lombok · SpringDoc OpenAPI