A practical guide to production-grade Spring Boot folder structure and layered architecture, covering controllers, services, repositories, DTOs, entities, BaseResponse usage, and global exception handling.
This is Part 3 of the Production-Grade Spring Boot API Design series.
Code Gihub Repo: https://github.com/Pratik280/order-system/tree/develop
In production Spring Boot applications, folder structure is not cosmetic.
It enforces separation of concerns, improves readability, and allows teams to scale safely.
Below is a typical production-ready package structure used for REST APIs.
com.example.orderservice
│
├── controller
│ └── OrderController.java
│
├── entity
│ └── OrderEntity.java
│
├── dto
│ ├── OrderRequest.java
│ └── OrderResponse.java
│
├── repository
│ └── OrderRepository.java
│
├── service
│ ├── OrderService.java
│ └── impl
│ └── OrderServiceImpl.java
│
├── exception
│ ├── BusinessException.java
│ └── ResourceNotFoundException.java
│
├── common
│ ├── BaseResponse.java
│ └── ResponseBuilder.java
│
└── handler
└── GlobalExceptionHandler.java
Each layer has one clear responsibility and does not leak concerns into other layers.
Controller Layer — API Entry Point
@RestController
@RequestMapping("/orders")
public class OrderController {
private OrderService orderService;
public OrderController(OrderService orderService){
this.orderService = orderService;
}
@GetMapping
public ResponseEntity<BaseResponse<List<OrderResponse>>> getAllOrders(){
return ResponseBuilder.success(
HttpStatus.OK,
"All orders fetch successfully",
orderService.getAllOrders()
);
}
@GetMapping("/{id}")
public ResponseEntity<BaseResponse<OrderResponse>> getOrderById(@PathVariable Long id){
return ResponseBuilder.success(
HttpStatus.OK,
"Order fetch successfully",
orderService.getOrderById(id)
);
}
@PostMapping
public ResponseEntity<BaseResponse<OrderResponse>> createOrder(
@Valid @RequestBody OrderRequest orderRequest){
return ResponseBuilder.success(
HttpStatus.OK,
"Order created successfully",
orderService.createOrder(orderRequest)
);
}
Annotations Explained
- @RestController
Marks the class as a REST API controller and automatically serializes responses to JSON.
- @RequestMapping("/orders")
Defines a base URL for all endpoints in this controller.
- @GetMapping
Handles HTTP GET requests.
- @PostMapping
Handles HTTP POST requests.
- @PathVariable
Extracts values from the URL path.
- @RequestBody
Converts incoming JSON into a Java object.
Triggers validation on request DTOs.
Controllers do not contain business logic. They only orchestrate requests and responses.
Entity Layer — Database Representation
@Entity
@Table(name = "orders")
@Getter
@Setter
@NoArgsConstructor
public class OrderEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String productName;
}
Entities:
- Represent database tables
- Are used only in the persistence layer
- Are never exposed directly to API clients
DTO Layer — API Contracts
@Getter
@Setter
@NoArgsConstructor
public class OrderRequest {
@NotBlank(message = "productName is mandatory")
private String productName;
}
@Getter
@Setter
@NoArgsConstructor
public class OrderResponse {
private Long id;
private String productName;
}
DTOs:
- Protect internal entity structure
- Allow validation rules
- Decouple API contracts from database design
Repository Layer — Data Access
public interface OrderRepository
extends JpaRepository<OrderEntity, Long> { }
Repositories:
- Abstract database access
- Provide CRUD operations automatically
- Remove the need for DAOs and JDBC code
Service Layer — Business Logic Contract
public interface OrderService {
List<OrderResponse> getAllOrders();
OrderResponse getOrderById(Long id);
OrderResponse createOrder(OrderRequest orderRequest);
}
The service interface defines what the system can do, not how.
Service Implementation — Business Logic Execution
@Service
public class OrderServiceImpl implements OrderService {
private OrderRepository orderRepository;
private ModelMapper modelMapper;
public OrderServiceImpl(OrderRepository orderRepository,
ModelMapper modelMapper){
this.orderRepository = orderRepository;
this.modelMapper = modelMapper;
}
@Override
public List<OrderResponse> getAllOrders(){
List<OrderResponse> orders = orderRepository.findAll()
.stream()
.map(orderEntity ->
modelMapper.map(orderEntity, OrderResponse.class))
.toList();
if(orders.isEmpty()){
throw new ResourceNotFoundException("No orders found");
}
return orders;
}
@Override
public OrderResponse getOrderById(Long id){
return orderRepository.findById(id)
.map(orderEntity ->
modelMapper.map(orderEntity, OrderResponse.class))
.orElseThrow(() ->
new ResourceNotFoundException(
"Order not found with id " + id));
}
@Override
public OrderResponse createOrder(OrderRequest orderRequest) {
OrderEntity orderEntity;
try{
orderEntity = modelMapper.map(orderRequest, OrderEntity.class);
} catch (Exception e){
throw new BusinessException(
"MAPPING_ERROR",
"Failed to map order request");
}
try{
OrderEntity saved = orderRepository.save(orderEntity);
return modelMapper.map(saved, OrderResponse.class);
} catch (Exception e){
throw new BusinessException(
"DATA_INTEGRITY_VIOLATION",
"Invalid Order Data");
}
}
}
Service layer:
- Contains all business rules
- Throws domain-specific exceptions
- Never returns ResponseEntity
- Never handles HTTP concerns
ModelMapper — Clean Entity ↔ DTO Mapping
In production systems, entities should never be exposed directly to APIs.
Entities represent database structure, while APIs should expose stable, client-friendly contracts using DTOs.
This is where ModelMapper is used.
What ModelMapper Does
ModelMapper automatically maps data between objects with similar structures, such as:
-
OrderEntity→OrderResponse -
OrderRequest→OrderEntity
It eliminates manual field-by-field mapping code.
How ModelMapper Is Used in This Project
modelMapper.map(orderEntity, OrderResponse.class);
modelMapper.map(orderRequest, OrderEntity.class);
In this implementation:
- Incoming API requests (OrderRequest) are mapped to persistence entities (OrderEntity)
- Saved entities are mapped back to response DTOs (OrderResponse)
- Controllers never deal with entities
- Services control all mapping logic
ModelMapper — Why We Use It
- Reduces boilerplate: Removes repetitive manual mapping code and prevents missing fields.
- Decouples layers: Keeps API DTOs independent from persistence entities.
- Improves maintainability: Makes refactoring DTOs or entities easier without breaking business logic.
- Right layer responsibility: Mapping lives in the service layer where business logic and data transformation belong, keeping controllers and repositories clean.
Custom Business Exceptions
public class BusinessException extends RuntimeException {
private final String errorCode;
public BusinessException(String message){
super(message);
this.errorCode = "BUSINESS_ERROR";
}
public BusinessException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message){
super(message);
}
}
How This Integrates with BaseResponse & GlobalExceptionHandler
Refer Part 2
- Controllers always return BaseResponse using ResponseBuilder
- Services throw meaningful domain exceptions
- @RestControllerAdvice catches exceptions globally
- One consistent response format across the entire application
Final Result
- Clean folder structure
- Clear separation of responsibilities
- Consistent API responses
- Centralized error handling
- Production-ready Spring Boot API design
This concludes the Production-Grade Spring Boot API Design series — based on real project experience and refined personal notes.
The goal was simple:
APIs that are clean, consistent, scalable, and interview-ready.
📌 This blog is based on real project experience and refined personal notes.
Top comments (1)
Great content, helped a lot to understand exception handling. If you're interested in spring boot related content, follow me for such content. I upload daily on fundamental topics of Spring Boot, you might like that..... Happy learning