Hook: Interviewers ask this because they want to know whether your app is a clean house or a junk drawer with one giant controller.
Question: Explain your Spring Boot project architecture.
Answer: Spring Boot project architecture is how I split the app so each part has one job. I usually keep controllers for HTTP, services for business rules, repositories for data access, DTOs for request and response data, and config or exception classes for cross-cutting concerns. In larger apps, I organize by feature, like order, payment, or user, and keep those same layers inside each feature.
Interview-Ready Answer: In my Spring Boot projects, I use a layered structure with a feature-first layout. The controller only handles HTTP and maps requests to DTOs, the service holds business logic and transaction boundaries, and the repository talks to the database. I keep entities out of the API and use DTOs so changes in persistence do not break clients. For cross-cutting concerns like errors, security, and configuration, I use dedicated classes so the code stays testable and easy to maintain.
Think of architecture as the path a request follows from the outside world to your database and back. Good architecture keeps each step small and predictable, so one bug does not spread everywhere. In Spring Boot, that usually means a controller layer, a service layer, a repository layer, plus DTOs, config, security, and exception handling.
order/controller for REST endpointsorder/service for business rulesorder/repository for persistenceorder/dto for request and response modelsorder/entity for database objectscommon/exception and common/config for shared concernsThis is often called a feature-first layout: inside each feature, you still keep layers, but the code stays grouped by business area. That helps when a system grows from 5 endpoints to 50.
ApplicationContext, which is the container that owns your beans. It scans the package tree below the main class, so your package placement matters.200, so if you block threads with slow work, requests queue up fast.@RestControllerAdvice.Most Spring Boot apps start with layered architecture because it is simple and familiar. For very complex systems, teams sometimes move toward hexagonal architecture, also called ports and adapters, where the business core is isolated from frameworks.
| Style | Best for | Trade-off |
|---|---|---|
| Layered | CRUD and service apps | Can get coupled if controllers call repositories directly |
| Hexagonal | Complex domain logic | More files and more abstraction |
Rule of thumb: start layered, keep feature grouping, and evolve only when complexity demands it.
10 if you do not tune it. Its default connection timeout is 30000 ms.O(1) per request; the real cost is usually I/O, not Java method calls.jakarta.* packages, while older Boot 2 code often used javax.*. This is a common interview gotcha when reading older tutorials.So, when I explain architecture, I focus on flow, separation of concerns, and where the business rules live. That tells the interviewer I understand not just the folder structure, but why the structure keeps the app safe as it grows.
Imagine a checkout service for an online store. The API receives an order request, the service validates the cart, the repository saves the order, and another client call reserves inventory or creates a payment record. This is exactly where architecture matters, because one request may touch multiple systems.
In one production incident, a developer put part of the payment flow directly in the controller because it looked faster to ship. Under load, the controller thread waited on a slow payment call while still holding database work open. The symptoms were ugly: checkout latency jumped, Tomcat worker threads filled up, the connection pool got exhausted, and users saw timeouts instead of confirmations. Logs showed repeated retries and stack traces from a blocked request path.
The fix was architectural, not just a code tweak. The team moved validation and orchestration into the service layer, made the controller thin again, and added a global exception handler so failed payments returned a clean 502 or 409 instead of a generic 500. After that, the request path became easier to trace, test, and scale.
What goes wrong when architecture is misunderstood: business logic leaks into controllers, transactions become unclear, retries cause duplicate writes, and bugs show up as random user-facing failures instead of one obvious place in the code.
// File: src/main/java/com/example/architecture/ArchitectureDemoApplication.java
package com.example.architecture;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class ArchitectureDemoApplication {
public static void main(String[] args) {
SpringApplication.run(ArchitectureDemoApplication.class, args);
}
@Bean
CommandLineRunner seedDemoData(OrderService orderService) {
// Seed data helps show that the service layer can be called from anywhere,
// not just from HTTP. In a real app, the same service would be reused by tests,
// schedulers, or message listeners.
return args -> orderService.createOrder(new CreateOrderRequest("Keyboard", 1, 49.99));
}
}
// File: src/main/java/com/example/architecture/ApiModels.java
package com.example.architecture;
// These are DTOs: small objects that cross the HTTP boundary without exposing persistence details.
record CreateOrderRequest(String item, int quantity, double unitPrice) {}
record OrderResponse(Long id, String item, int quantity, double unitPrice, double total) {}
// Package-private exception is enough because it is only used inside this package.
class NotFoundException extends RuntimeException {
NotFoundException(String message) {
super(message);
}
}
// File: src/main/java/com/example/architecture/OrderEntity.java
package com.example.architecture;
// This is a persistence model. In a real JPA app, this might be an @Entity.
class OrderEntity {
private Long id;
private String item;
private int quantity;
private double unitPrice;
OrderEntity(Long id, String item, int quantity, double unitPrice) {
this.id = id;
this.item = item;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
OrderEntity copy() {
return new OrderEntity(id, item, quantity, unitPrice);
}
Long getId() {
return id;
}
void setId(Long id) {
this.id = id;
}
String getItem() {
return item;
}
int getQuantity() {
return quantity;
}
double getUnitPrice() {
return unitPrice;
}
}
// File: src/main/java/com/example/architecture/OrderRepository.java
package com.example.architecture;
import java.util.List;
import java.util.Optional;
// Repository is the data-access boundary. The service should not know whether this is a DB, a map, or a remote store.
public interface OrderRepository {
OrderEntity save(OrderEntity entity);
Optional<OrderEntity> findById(Long id);
List<OrderEntity> findAll();
}
// File: src/main/java/com/example/architecture/InMemoryOrderRepository.java
package com.example.architecture;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Repository;
@Repository
class InMemoryOrderRepository implements OrderRepository {
private final ConcurrentMap<Long, OrderEntity> store = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong(0);
@Override
public OrderEntity save(OrderEntity entity) {
// Assign an ID only when needed. This mimics a database-generated key.
Long id = entity.getId();
if (id == null) {
id = sequence.incrementAndGet();
}
OrderEntity saved = new OrderEntity(id, entity.getItem(), entity.getQuantity(), entity.getUnitPrice());
store.put(id, saved);
return saved.copy();
}
@Override
public Optional<OrderEntity> findById(Long id) {
OrderEntity entity = store.get(id);
return entity == null ? Optional.empty() : Optional.of(entity.copy());
}
@Override
public List<OrderEntity> findAll() {
return store.values().stream()
.map(OrderEntity::copy)
.sorted(Comparator.comparing(OrderEntity::getId))
.toList();
}
}
// File: src/main/java/com/example/architecture/OrderService.java
package com.example.architecture;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
// Constructor injection makes dependencies obvious and avoids nulls.
this.orderRepository = orderRepository;
}
public OrderResponse createOrder(CreateOrderRequest request) {
validate(request);
OrderEntity saved = orderRepository.save(
new OrderEntity(null, request.item(), request.quantity(), request.unitPrice())
);
return toResponse(saved);
}
public OrderResponse getOrder(Long id) {
OrderEntity entity = orderRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Order " + id + " not found"));
return toResponse(entity);
}
public List<OrderResponse> listOrders() {
return orderRepository.findAll().stream()
.map(this::toResponse)
.toList();
}
private void validate(CreateOrderRequest request) {
if (request == null) {
throw new IllegalArgumentException("Request body is required");
}
if (request.item() == null || request.item().isBlank()) {
throw new IllegalArgumentException("Item is required");
}
if (request.quantity() <= 0) {
throw new IllegalArgumentException("Quantity must be greater than zero");
}
if (request.unitPrice() <= 0.0) {
throw new IllegalArgumentException("Unit price must be greater than zero");
}
}
private OrderResponse toResponse(OrderEntity entity) {
double total = entity.getQuantity() * entity.getUnitPrice();
return new OrderResponse(entity.getId(), entity.getItem(), entity.getQuantity(), entity.getUnitPrice(), total);
}
}
// File: src/main/java/com/example/architecture/OrderController.java
package com.example.architecture;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) {
// The controller stays thin: it only translates HTTP to a service call.
OrderResponse response = orderService.createOrder(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping("/{id}")
public OrderResponse getById(@PathVariable Long id) {
return orderService.getOrder(id);
}
@GetMapping
public List<OrderResponse> list() {
return orderService.listOrders();
}
}
// File: src/main/java/com/example/architecture/ApiExceptionHandler.java
package com.example.architecture;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(NotFoundException ex) {
return build(HttpStatus.NOT_FOUND, "NOT_FOUND", ex.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleBadRequest(IllegalArgumentException ex) {
return build(HttpStatus.BAD_REQUEST, "BAD_REQUEST", ex.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception ex) {
// A last-resort handler keeps clients from seeing a raw stack trace.
return build(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "Something went wrong");
}
private ResponseEntity<Map<String, Object>> build(HttpStatus status, String error, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", error);
body.put("message", message);
body.put("status", status.value());
return ResponseEntity.status(status).body(body);
}
}
/*
How to try it:
1) Start the app.
2) POST /orders with:
{"item":"Mouse","quantity":2,"unitPrice":25.0}
3) GET /orders/1 to fetch the seeded record.
4) GET /orders/999 to see the 404 path.
5) POST /orders with {"item":"","quantity":0,"unitPrice":-1}
to see validation handled as a clean 400.
*/Follow-up & Tricky Questions:
@Transactional live? Usually on the service layer, because that is where the business use case is orchestrated. Putting it in the controller mixes HTTP concerns with transaction control.@RestControllerAdvice and focused handlers for validation, not found, and unexpected errors. That gives clients consistent status codes and messages.Common Mistakes:
Memory Hook: Think of a restaurant: the controller is the front desk taking the order, the service is the manager deciding what should happen, and the repository is the kitchen or warehouse that stores and prepares the goods.
Cheat Sheet:
jakarta.*; older tutorials may use javax.*.Practice Tasks:
/products API with controller, service, and repository layers.@RestControllerAdvice and return clean 400 and 404 responses.order, payment, and user.