How a Spring Boot Controller Catches an HTTP Request and Turns It into Java Objects
This is the third article in the "Frontend Engineer Transitioning to Backend" series. In the first article, we established the project map; in the second, we understood how Java, Maven, and Spring Boot start a project as a backend service. Starting from this article, we officially enter "API development" itself: how does an HTTP request sent by a browser or curl get matched to the corresponding Controller in Spring Boot? How do path parameters, query parameters, and JSON body in the URL become Java objects? Why does the backend need to return a unified
ApiResponse<T>, and why can't database Entities be directly thrown to the frontend?
Note: This article is still based on the current
fullstack-mallbackend project. All frontend code in the text is "frontend-side illustrative code," used only for analogies with Axios, forms, and TypeScript interfaces, and does not represent the existence of actual frontend source code in the repository.
1. What This Article Solves
When frontend developers start learning the backend, the easiest thing to understand is usually "APIs." Because you write code like this every day:
// Frontend-side illustrative code: no actual frontend source code in the current repository
const res = await axios.get('/api/products', {
params: {
pageNum: 1,
pageSize: 10,
keyword: 'phone'
}
})
Or:
// Frontend-side illustrative code: no actual frontend source code in the current repository
const res = await axios.post('/api/admin/products', {
categoryId: 1,
title: 'New Product',
description: 'Product description'
})
You know the frontend sends HTTP requests, and you know you can see the URL, method, query string, request payload, and response JSON in the browser's Network panel. But when you open the backend code, you see:
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping
public ApiResponse<PageResponse<ProductSummaryResponse>> queryProducts(
@Valid @ParameterObject ProductQueryRequest request,
HttpServletRequest servletRequest
) {
...
}
}
For a frontend developer, there are many new concepts here: @RestController, @RequestMapping, @GetMapping, @PathVariable, @RequestBody, @Valid, ResponseEntity, ApiResponse<T>. If you just memorize the annotations, you'll quickly get confused: When do you use query parameters? When do you use a body? Why do some APIs return ResponseEntity<ApiResponse<...>> while others return ApiResponse<...> directly? Why does a validation failure not enter the business method but still return a unified JSON?
This article solves the following problems:
- How Spring Boot routes an HTTP request to a Controller method;
- The responsibilities of
@RestControllerand@RequestMappingin the current project; - How a GET query API receives query parameters;
- How
{id}in a path is bound to a JavaLong id; - How POST / PATCH APIs receive a JSON body;
- How to understand the boundaries of Request DTO, Response DTO, and Entity;
- How
@Validand Bean Validation turn parameter errors into 400 responses; - Why
ApiResponse<T>is a unified response envelope for the frontend; - How
GlobalExceptionHandlerconverts exceptions into stable responses; - How to use curl to verify three types of results: success, parameter error, and non-existent path.
After studying this article, you should be able to take an API endpoint and reverse-locate the backend Controller method; you should also be able to deduce from a Controller method how the frontend should pass parameters and what response structure it will receive.
2. Using Frontend Knowledge as an Analogy: From Axios to Controller
When the frontend calls an API, it usually focuses on three things: the request address, request parameters, and response data. The backend Controller also revolves around these three things, just from the "receiver's" perspective.
| Frontend Perspective | Backend Perspective | Current Project Example |
|---|---|---|
axios.get('/api/products') |
@GetMapping receives GET request |
ProductController.queryProducts(...) |
params: { pageNum: 1 } |
query parameters bound to Request DTO | ProductQueryRequest.pageNum |
/api/products/1 in URL |
@PathVariable Long id |
ProductController.detail(...) |
axios.post(url, body) |
@RequestBody reads JSON body |
ProductAdminController.createProduct(...) |
| TypeScript interface | Java Request / Response DTO | ProductCreateRequest, ProductDetailResponse |
| Axios response interceptor | Backend unified response envelope | ApiResponse<T> |
| Form validation error display | Bean Validation field errors | ValidationErrorData.fieldErrors |
| Viewing status code in Network | ResponseEntity sets HTTP status code |
Creating a product returns 201 Created |
The frontend sends requests, the backend receives them. You can think of a Controller as a "server-side routing component." But it's not exactly the same as Vue Router. Vue Router maps browser URLs to page components; a Spring Controller maps HTTP requests to Java methods. Page components usually return a DOM or component tree; Controller methods usually return a Java object, which Spring Boot ultimately serializes into JSON.
The complete request chain for an API can be drawn like this:
sequenceDiagram
participant FE as Frontend Page / curl
participant Net as HTTP Request
participant MVC as Spring MVC Route Matching
participant C as Controller Method
participant F as Facade Business Entry
participant R as ApiResponse Envelope
FE->>Net: GET /api/products?pageNum=1&pageSize=10
Net->>MVC: method + path + query
MVC->>C: Bind ProductQueryRequest
C->>F: queryPublishedProducts(request)
F-->>C: PageResponse
C->>R: ApiResponse.success(data, traceId)
R-->>FE: JSON Response
For frontend developers, the most important mindset shift is: previously you only cared about "how do I call an API," now you need to care about "how do I define a stable contract when others call my API." The contract includes the address, method, parameter location, field types, validation rules, response structure, error codes, and HTTP status codes. Once the backend provides an external API, it must try to keep these contracts stable, because the frontend, tests, documentation, and callers will all depend on them.
3. Core Backend Concept Explanation
3.1 What is a Controller
A Controller is the entry point of the Web layer. In the current project, the entry point for public product APIs is:
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductController.java
The entry point for admin product APIs is:
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductAdminController.java
The Controller's responsibility is not to "write all the business logic," but to handle HTTP-related matters:
- Define URL prefixes and specific paths;
- Specify HTTP methods like GET, POST, PATCH;
- Receive query, path, and body parameters;
- Trigger parameter validation;
- Call a Facade or Service to complete the business logic;
- Wrap the business result into a unified response;
- Set HTTP status codes when necessary.
It's more like an API route or BFF layer entry point in the frontend, rather than a "universal business class." If a Controller is piled with database queries, inventory deductions, transaction judgments, and cache processing, it will become very difficult to maintain later. The current project uses Controllers to call IProductFacade, which then organizes the subsequent business logic; this boundary is suitable for beginners to understand.
3.2 @RestController: Telling Spring This is a JSON API Entry Point
The current ProductController class has:
@RestController
@RequestMapping("/api/products")
@Tag(name = "Public Product")
public class ProductController {
...
}
@RestController can initially be understood as having two meanings:
- This class will be scanned by Spring and created as a Bean;
- The return value of methods in this class will be used as the response body, typically serialized into JSON.
In early Spring MVC, @Controller was commonly used to return page templates, while @RestController is more suitable for REST APIs. For the current project, all APIs are JSON APIs called by the frontend, curl, or Swagger, so the Controller classes use @RestController.
Frontend analogy: If a Vue component is responsible for rendering a page, then a Controller is not a "page component" but an "API handler." It does not return HTML, but serializable data objects.
3.3 @RequestMapping and Specific Mappings: Class-level Path + Method-level Path
The class-level path for the current public product Controller is:
@RequestMapping("/api/products")
On the methods, there are:
@GetMapping
and:
@GetMapping("/{id}")
The final combination produces two APIs:
GET /api/products
GET /api/products/{id}
This is very similar to "parent route + child route" in frontend routing. For example, you can view /api/products as the parent path, an empty child path represents the list, and /{id} represents the detail. The class-level path for the admin product Controller is /api/admin/products, so its @PostMapping corresponds to:
POST /api/admin/products
Its @PatchMapping("/{id}/status") corresponds to:
PATCH /api/admin/products/{id}/status
Its @PatchMapping("/{id}/subtitle") corresponds to:
PATCH /api/admin/products/{id}/subtitle
The path combination rule can be drawn like this:
flowchart TD
A["ProductController class-level path /api/products"] --> B["@GetMapping empty path"]
A --> C["@GetMapping /{id}"]
B --> D["GET /api/products"]
C --> E["GET /api/products/{id}"]
F["ProductAdminController class-level path /api/admin/products"] --> G["@PostMapping empty path"]
F --> H["@PatchMapping /{id}/status"]
F --> I["@PatchMapping /{id}/subtitle"]
G --> J["POST /api/admin/products"]
H --> K["PATCH /api/admin/products/{id}/status"]
I --> L["PATCH /api/admin/products/{id}/subtitle"]
When starting out, you must develop the habit of "looking at the class-level path and method-level path together." Many 404 errors are not business code errors, but because you only looked at the @GetMapping on the method and forgot to add the @RequestMapping on the class.
3.4 Query Parameters: How a GET List Query Binds a Request DTO
The public product pagination query method is:
@GetMapping
@Operation(summary = "Paginated query for listed products")
public ApiResponse<PageResponse<ProductSummaryResponse>> queryProducts(
@Valid @ParameterObject ProductQueryRequest request,
HttpServletRequest servletRequest
) {
PageResponse<ProductSummaryResponse> response = productFacade.queryPublishedProducts(request);
return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}
This method receives a GET request. GET requests typically do not use a JSON body but place filter conditions in the URL query string, for example:
curl 'http://localhost:8080/api/products?pageNum=1&pageSize=10&keyword=phone&categoryId=1'
Spring will attempt to bind these query parameters to the ProductQueryRequest object. This class is in the contract module:
public class ProductQueryRequest {
private Integer pageNum = 1;
private Integer pageSize = 10;
private String keyword;
private Long categoryId;
private ProductStatus status;
}
This is very similar to a frontend TypeScript interface:
// Frontend-side illustrative code
interface ProductQueryRequest {
pageNum?: number
pageSize?: number
keyword?: string
categoryId?: number
status?: 'DRAFT' | 'ON_SALE' | 'OFF_SALE'
}
But a Java DTO has a capability that a frontend interface does not: it can carry server-side validation rules. For example, pageNum has @Min(value = 1), pageSize has @Max(value = 100), and categoryId has @Positive. The current Controller parameter has @Valid in front of it, meaning validation will be triggered after binding is complete. If you request:
curl 'http://localhost:8080/api/products?pageNum=0&pageSize=200'
The backend will consider the parameters invalid, throw a validation exception, and return a 400 via the unified exception handler. Note that this error is caught before entering the business query. Its value is similar to frontend form validation, but more reliable, because the server cannot trust that the frontend has definitely performed validation.
3.5 Path Parameters: How {id} in a Detail API Becomes Long id
The product detail method is:
@GetMapping("/{id}")
@Operation(summary = "Query listed product details")
public ApiResponse<ProductDetailResponse> detail(
@PathVariable Long id,
HttpServletRequest servletRequest
) {
ProductDetailResponse response = productFacade.getPublishedProduct(id);
return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}
Here, /{id} indicates a dynamic segment in the path. A request to:
GET /api/products/1001
will bind 1001 to the method parameter Long id. The frontend analogy is dynamic routing in Vue Router:
// Frontend-side illustrative code
{
path: '/products/:id',
component: ProductDetailPage
}
In a frontend page, you might write route.params.id; in the backend, it's @PathVariable Long id. The difference is: what the frontend gets is usually a string first, while the backend will try to convert it to Long. If the path passes /api/products/abc, the type conversion will fail, and the backend will not treat abc as a valid product ID.
Path parameters are suitable for representing "resource identifiers." For example, for product details, order details, or modifying a product's status, putting the id in the path is natural. Query parameters are suitable for representing filter conditions, pagination conditions, and sorting conditions. A JSON body is suitable for representing complex submitted data. Don't stuff all parameters into the query, and don't make every operation a POST body; API readability is itself a part of backend design.
3.6 JSON Body: How POST / PATCH Receives Complex Objects
The admin create product API is:
@PostMapping
@Operation(summary = "Create product draft")
public ResponseEntity<ApiResponse<ProductDetailResponse>> createProduct(
@Valid @RequestBody ProductCreateRequest request,
HttpServletRequest servletRequest
) {
ProductDetailResponse response = productFacade.createProduct(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(response, TraceIdContext.get(servletRequest)));
}
There are two key annotations here: @RequestBody and @Valid. @RequestBody indicates reading JSON from the HTTP request body and converting it into a Java object. @Valid indicates continuing to validate field rules after successful conversion.
The frontend-side request is roughly:
// Frontend-side illustrative code
await axios.post('/api/admin/products', {
categoryId: 1,
title: 'New Product',
description: 'This is a product description'
})
The backend ProductCreateRequest defines the fields allowed to be submitted:
public class ProductCreateRequest {
@NotNull(message = "Product category cannot be empty")
@Positive(message = "Product category ID must be greater than 0")
private Long categoryId;
@NotBlank(message = "Product title cannot be empty")
@Size(max = 100, message = "Product title cannot exceed 100 characters")
private String title;
@Size(max = 2000, message = "Product description cannot exceed 2000 characters")
private String description;
}
This code reflects a very important idea in backend API design: What the frontend can send must be defined by a server-side whitelist. When creating a product, the frontend can only submit the category, title, and description. It cannot submit a product ID, because the ID should be generated by the database; it cannot submit a creator ID, because the creator should come from the login context; it cannot arbitrarily submit a creation time, because the time should be generated by the server; nor can it directly submit a status transition result, because status changes should go through dedicated APIs and business rules.
This is similar to a frontend form model, but the backend requirements are stricter. A frontend form can have extra fields for display convenience, but a backend Request DTO must only contain the fields that the current operation allows for external input. Otherwise, privilege escalation, dirty data, or security vulnerabilities will occur.
3.7 Boundaries of Request DTO, Response DTO, and Entity
Many frontend developers, when first writing backend code, ask: Why can't the Controller directly receive a database Entity? Why can't it directly return an Entity? The reason is that Request, Response, and Entity represent three different boundaries.
flowchart LR
A[Frontend Request JSON] --> B[Request DTO\nExternal Input Whitelist]
B --> C[Business Logic\nValidation, Supplement, Conversion]
C --> D[Entity\nDatabase Table Mapping]
D --> E[Query Assembly]
E --> F[Response DTO\nExternal Output Contract]
F --> G[Frontend Response JSON]
A Request DTO faces "external input." It answers: What fields does this API allow the caller to submit? What validation rules do these fields have? Which fields must be generated by the server itself and must never be passed by the frontend?
A Response DTO faces "external output." It answers: What fields does this API promise to return? Which fields are for page display? Which fields should be hidden? How do field names and structures remain stable? The current project's ProductDetailResponse includes fields like id, categoryId, categoryName, title, subtitle, description, status, createdBy, createdAt, updatedAt. It is a contract for the frontend to view product details.
An Entity faces the "database table." It answers: What fields are in the table? How do Java types map to database types? How are field names converted from snake_case to camelCase? Changes to an Entity are often related to database structure, while changes to Request/Response are related to the API contract. The two should not be forcibly mixed together.
Frontend analogy: You wouldn't use the raw backend API response directly as all the internal state of a page, nor would you submit a component's internal temporary fields as-is to an API. You distinguish between form model, view model, and API payload. The backend is the same, but the boundaries are stricter because the backend also needs to protect the database, security, and business rules.
3.8 ApiResponse<T>: Unified Response Envelope
All business APIs in the current project share a unified response envelope:
public class ApiResponse<T> {
private String code;
private String message;
private T data;
private String traceId;
private Instant timestamp;
}
For success, use:
ApiResponse.success(response, TraceIdContext.get(servletRequest))
For errors, use:
ApiResponse.error(code, message, data, traceId)
After the frontend receives the response, it should not only look at the HTTP status code, nor should it parse the Chinese message. A more stable way is to look at the code. Because message is human-readable text that the product team might adjust in the future; code is a stable business code for the program to judge, such as SUCCESS, VALIDATION_ERROR, PRODUCT_NOT_FOUND, UNAUTHORIZED, FORBIDDEN.
There are many benefits to a unified response envelope:
- The frontend response interceptor can handle
codeuniformly; - The error structure is stable; form errors, business errors, and system errors all have fixed positions;
traceIdhelps with frontend-backend debugging; when the frontend reports an error, it gives the traceId to the backend, and the backend can locate it in the logs;timestampindicates the response generation time, making it easy to troubleshoot caching, timezone, and retry issues;- The generic
Tkeeps thedatatype clear for different APIs.
The frontend side can understand it like this:
// Frontend-side illustrative code
interface ApiResponse<T> {
code: string
message: string
data: T
traceId: string
timestamp: string
}
If an API returns product details, then T is ProductDetailResponse; if it returns a paginated product list, then T is PageResponse<ProductSummaryResponse>; if it returns field validation errors, then T is ValidationErrorData. This is where Java generics and TypeScript generics are very similar.
3.9 ResponseEntity: Why Creating a Product Returns 201
Most query APIs directly return ApiResponse<T>, and Spring Boot will use a 200 status code by default. But the create product API returns:
ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(response, TraceIdContext.get(servletRequest)))
HttpStatus.CREATED corresponds to HTTP 201. The semantics it expresses are: the server has created a new resource. After a successful call, the frontend can not only get the product details from the response body but also know from the HTTP status code that this was a successful creation.
Frontend developers might have previously only cared about res.data, but after doing backend work, you need to pay more attention to HTTP semantics. Common status codes can be initially understood as follows:
| Status Code | Meaning | Typical Scenario in Current Project |
|---|---|---|
| 200 | Request successful | Querying lists, querying details, successful status modification |
| 201 | Created successfully | Successfully creating a product draft |
| 400 | Request parameter or JSON format error | @Valid validation failure, JSON syntax error |
| 401 | Not logged in or login expired | To be covered in subsequent JWT / Security chapters |
| 403 | Logged in but no permission | Insufficient permissions for admin APIs |
| 404 | Path or resource not found | Path does not exist, or similar business code may be used when a business resource does not exist |
| 500 | Unexpected server error | Fallback exception handling |
HTTP status codes are protocol-level semantics, while ApiResponse.code is business-level semantics. The two are not substitutes for each other but are used in coordination.
3.10 @Valid and Unified Exception Handling
Parameter validation in the current project is not scattered across each Controller with handwritten if statements. It uses Jakarta Validation annotations, for example:
@NotBlank(message = "Product title cannot be empty")
@Size(max = 100, message = "Product title cannot exceed 100 characters")
private String title;
After adding @Valid to the Controller parameter, Spring will automatically validate before calling the business method. If validation fails, it throws a MethodArgumentNotValidException. This exception is handled by:
backend/service/src/main/java/com/example/fullstackmall/service/common/exception/GlobalExceptionHandler.java
in a unified manner. handleValidation(...) converts field errors into ValidationErrorData and returns a 400. This way, the frontend receives structured field errors, not an unparseable exception stack trace.
This has similarities to frontend form validation: the frontend might use rules to validate that title is required and no longer than 100 characters; the backend also validates with annotations. But their purposes are different. Frontend validation is for user experience, reducing invalid requests; backend validation is a security boundary, ensuring that no matter who the caller is, they cannot bypass the rules.
The parameter validation and exception handling chain can be drawn like this:
sequenceDiagram
participant FE as Frontend / curl
participant MVC as Spring MVC
participant V as Bean Validation
participant C as Controller
participant H as GlobalExceptionHandler
FE->>MVC: POST /api/admin/products JSON
MVC->>MVC: JSON to Java Object
MVC->>V: Execute @Valid Validation
alt Validation Passes
V->>C: Call Controller Method
C-->>FE: ApiResponse SUCCESS
else Validation Fails
V->>H: MethodArgumentNotValidException
H-->>FE: HTTP 400 + VALIDATION_ERROR
end
This diagram explains a common phenomenon: sometimes you set a breakpoint on the first line of a Controller method, but the request doesn't enter. The reason might not be a route mismatch, but that JSON parsing or parameter validation failed before entering the method.
4. Corresponding Files in This Project
This chapter mainly reads these files:
| File | Focus of This Chapter |
|---|---|
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductController.java |
Public product list and detail APIs, learning GET, query, path parameters |
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductAdminController.java |
Admin create, modify status, modify subtitle, paginated query APIs, learning POST, PATCH, body, 201 |
backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductQueryRequest.java |
GET query parameter DTO and field validation |
backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductCreateRequest.java |
Create product body DTO and field validation |
backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductDetailResponse.java |
Product detail Response DTO |
backend/contract/src/main/java/com/example/fullstackmall/contract/common/ApiResponse.java |
Unified response envelope for all business APIs |
backend/contract/src/main/java/com/example/fullstackmall/contract/common/ApiCode.java |
Stable business code enum; the frontend should prioritize checking code |
backend/contract/src/main/java/com/example/fullstackmall/contract/common/PageResponse.java |
Pagination response structure |
backend/contract/src/main/java/com/example/fullstackmall/contract/common/ValidationErrorData.java |
Parameter validation error response data |
backend/service/src/main/java/com/example/fullstackmall/service/common/exception/GlobalExceptionHandler.java |
Unified exception handling, converting Java exceptions to HTTP status codes and ApiResponse |
docs/backend-basics/backend-foundation/02-spring-boot-and-web.md |
Existing backend basics documentation, can be cross-referenced to understand Spring MVC, Controller, Bean |
This chapter does not delve into databases, Redis, or login permissions. Even though admin APIs will later involve Spring Security, here we only focus on how the Controller defines the API shape. The permission mechanism will be expanded in the JWT and Spring Security chapter.
5. Mermaid Diagram: An API from Matching to Response
Combining all the concepts from this chapter, we can get a "Request Processing Overview":
flowchart TD
A[HTTP Request] --> B{Does method + path match a Controller?}
B -- No --> N[GlobalExceptionHandler\nNOT_FOUND]
B -- Yes --> C[Parameter Binding]
C --> C1[query params -> Request DTO]
C --> C2["path params -> @PathVariable"]
C --> C3["JSON body -> @RequestBody"]
C1 --> D["@Valid Parameter Validation"]
C2 --> D
C3 --> D
D -- Failure --> E[MethodArgumentNotValidException]
E --> H[GlobalExceptionHandler]
H --> I[HTTP 400 + ApiResponse VALIDATION_ERROR]
D -- Success --> J[Controller Method]
J --> K[Call Facade]
K --> L[Return Response DTO]
L --> M[ApiResponse.success]
M --> O[JSON Response to Frontend]
This diagram is suitable for troubleshooting all API issues in the future. First determine if the path matches, then if the parameters can be bound, then if validation passes, then if the business logic succeeds, and finally if the response packaging meets expectations.
6. Reading the Source Code Section by Section
6.1 Reading the Class Definition of ProductController
The core code is:
@RestController
@RequestMapping("/api/products")
@Tag(name = "Public Product")
public class ProductController {
@Resource
private IProductFacade productFacade;
}
@RestController indicates this is a REST API Controller. @RequestMapping("/api/products") defines the common prefix for this group of APIs. @Tag is a label for OpenAPI / Swagger documentation; it doesn't affect business logic but affects the grouping display in API documentation.
@Resource private IProductFacade productFacade; indicates that the Controller does not create business objects itself but lets Spring inject a Bean that implements IProductFacade. Frontend analogy: you wouldn't re-implement a request library in every component, but import a packaged service; similarly, the backend doesn't write all business logic directly in the Controller but calls a Facade.
There's also a design detail here: the field type is the interface IProductFacade, not the concrete implementation ProductFacade. This reflects interface-oriented programming. The Controller only depends on "product business capabilities," not directly on "how product business is implemented." If the implementation later introduces caching, transactions, or MyBatis-Plus, the Controller's API shape doesn't have to change accordingly.
6.2 Reading the Public List API
@GetMapping
@Operation(summary = "Paginated query for listed products")
public ApiResponse<PageResponse<ProductSummaryResponse>> queryProducts(
@Valid @ParameterObject ProductQueryRequest request,
HttpServletRequest servletRequest
) {
PageResponse<ProductSummaryResponse> response = productFacade.queryPublishedProducts(request);
return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}
This method contains at least 6 pieces of information.
First, @GetMapping has no extra path, so the full path is the class-level path /api/products. The request method must be GET.
Second, ProductQueryRequest request is the query condition object. Because it doesn't have @RequestBody, it's not read from the JSON body but bound to query parameters. @ParameterObject mainly serves springdoc, allowing Swagger to display object fields as query parameters.
Third, @Valid triggers field validation. As long as pageNum is less than 1, or pageSize is greater than 100, a validation error will be triggered.
Fourth, HttpServletRequest servletRequest is not a business parameter but the underlying HTTP request object. The current project uses it to get the traceId from TraceIdContext and put it into the unified response.
Fifth, the Controller calls productFacade.queryPublishedProducts(request). The Published in the method name is crucial: the public product list can only query listed products. Even if ProductQueryRequest has a status field, the public query should not allow the frontend to arbitrarily query draft or off-sale products. This constraint is usually enforced in the Facade or DbService.
Sixth, the return type is ApiResponse<PageResponse<ProductSummaryResponse>>. Reading from the outside in: the outermost layer is the unified response envelope; data is the pagination object; the records inside the pagination object is the list of product summaries.
The frontend side can deduce that the response type is roughly:
// Frontend-side illustrative code
type ProductListResponse = ApiResponse<PageResponse<ProductSummaryResponse>>
6.3 Reading the Product Detail API
@GetMapping("/{id}")
@Operation(summary = "Query listed product details")
public ApiResponse<ProductDetailResponse> detail(
@PathVariable Long id,
HttpServletRequest servletRequest
) {
ProductDetailResponse response = productFacade.getPublishedProduct(id);
return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}
This method is simpler than the list API, but it demonstrates the binding of path parameters. The {id} in @GetMapping("/{id}") corresponds to @PathVariable Long id. When requesting /api/products/1, Spring converts the path segment 1 into a Long.
Here, it also calls getPublishedProduct(id), not "query any product arbitrarily." The public detail API should also only return listed products. This is important for the frontend: if the backend management system can see draft products, it doesn't mean the user-facing product detail page can also see them. Backend APIs are usually split by usage scenario, rather than one API satisfying all pages.
The returned ProductDetailResponse is the product detail DTO. It includes categoryName, which indicates that the response is not just a simple copy of product table fields but may have been assembled across tables. It also includes createdAt and updatedAt, fields that come from the server and database and should not be passed in by the frontend.
6.4 Reading the Admin Create API
@PostMapping
@Operation(summary = "Create product draft")
public ResponseEntity<ApiResponse<ProductDetailResponse>> createProduct(
@Valid @RequestBody ProductCreateRequest request,
HttpServletRequest servletRequest
) {
ProductDetailResponse response = productFacade.createProduct(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(response, TraceIdContext.get(servletRequest)));
}
The full path for this API is POST /api/admin/products. It differs from the public product list path by having the /admin prefix, indicating this is an admin capability. Subsequent security chapters will explain why admin APIs need authentication and authorization control.
@RequestBody is the most important annotation here. It tells Spring: the request body is JSON, map the JSON fields to ProductCreateRequest. If the request body is not valid JSON, for example missing a quote or having an extra comma, it will trigger an HttpMessageNotReadableException, which the global exception handler converts to MALFORMED_JSON.
The fields of ProductCreateRequest are very restrained: only categoryId, title, description. This is a whitelist design. When the frontend submits a create product form, the page might also have temporary image previews, rich text editing states, loading, and dirty flags, but none of these should enter the backend Request DTO. The backend only receives fields that are needed for the business and allowed for external input.
This method returns ResponseEntity<ApiResponse<ProductDetailResponse>> to set the HTTP 201 status. Compared to directly returning ApiResponse, ResponseEntity gives the Controller finer-grained control over the HTTP response. You will also see it later when you need to set status codes, response headers, or file downloads.
6.5 Reading the Admin PATCH APIs
There are also two PATCH APIs in the admin product Controller:
@PatchMapping("/{id}/status")
public ApiResponse<ProductDetailResponse> changeStatus(
@PathVariable Long id,
@Valid @RequestBody ProductStatusChangeRequest request,
HttpServletRequest servletRequest
) { ... }
and:
@PatchMapping("/{id}/subtitle")
public ApiResponse<ProductDetailResponse> updateSubtitle(
@PathVariable Long id,
@Valid @RequestBody ProductSubtitleUpdateRequest request,
HttpServletRequest servletRequest
) { ... }
PATCH usually indicates a partial modification to a resource. Here, the id in the path specifies "which product to modify," and the body specifies "what to modify it to." Putting the id in the path and the modification content in the body is a clear REST style.
Frontend-side illustration:
// Frontend-side illustrative code
await axios.patch(`/api/admin/products/${id}/subtitle`, {
subtitle: 'New Subtitle'
})
Note: Although both modifying the status and modifying the subtitle return ProductDetailResponse, their Request DTOs should not be mixed. Status changes have state machine rules, and subtitle modifications have field length and null value rules. Using a dedicated DTO for each operation makes the validation and business intent clearer.
6.6 Reading ProductQueryRequest
ProductQueryRequest is a typical query DTO. It includes default values:
private Integer pageNum = 1;
private Integer pageSize = 10;
This means that when the frontend doesn't pass pagination parameters, the server still has default pagination behavior. Never let a list API return all data by default; this is a fundamental principle of backend pagination design. Frontend infinite scrolling, paginated tables, and search lists should all cooperate with the backend pagination contract.
The validation annotations on the fields express the server's bottom line: the page number cannot be less than 1, the page size cannot be less than 1 or greater than 100; the keyword length cannot exceed 100; the category ID must be greater than 0. These restrictions are not to make things difficult for the frontend, but to protect the database and service stability. An API without a pagination limit can easily be brought down by a single request.
There is also a status field here, with a comment stating "Available for admin use; public queries will be forced to ON_SALE by the server and cannot be overridden by the frontend." This reflects an important principle: the same DTO can be reused in different scenarios, but permissions and business rules must be enforced by the backend. Even if a public API receives status=DRAFT, it should not allow anonymous users to query draft products.
6.7 Reading ProductCreateRequest
The create product request DTO embodies "input whitelist + field validation":
@NotNull(message = "Product category cannot be empty")
@Positive(message = "Product category ID must be greater than 0")
private Long categoryId;
@NotBlank(message = "Product title cannot be empty")
@Size(max = 100, message = "Product title cannot exceed 100 characters")
private String title;
@Size(max = 2000, message = "Product description cannot exceed 2000 characters")
private String description;
@NotNull means it cannot be null, but for a string, if it's blank, @NotBlank is also needed. @Positive means the number must be greater than 0. @Size can limit string length or collection length. When starting out, don't get bogged down in every parameter of every annotation; first remember that they are "server-side form rules."
When doing frontend forms, you also write required and length limits. But frontend validation is only the first layer of experience; backend validation is the final trusted layer. Anyone can bypass your page and use curl, Postman, or a script to directly request the API. If the backend doesn't validate, it's equivalent to leaving database security up to the caller's conscience.
6.8 Reading ProductDetailResponse
The product detail response DTO has these characteristics:
- Has the database primary key
id; - Has category ID and category name;
- Has title, subtitle, description;
- Has product status;
- Has creator and creation/update times.
It is an "external display model," not a database Entity. For example, categoryName likely comes from a category table, not necessarily a product table field. A Response DTO can combine data from multiple sources according to page needs. After the frontend receives it, it can be directly used for detail page display.
But a Response DTO must also be restrained. Don't return all internal fields to the frontend just because "they might be used later." The more you return, the heavier the contract, the higher the leakage risk, and the harder subsequent changes become. For example, fields like cost price, internal audit notes, deletion markers, and version numbers should not casually appear in public responses if the frontend page doesn't need them.
6.9 Reading ApiResponse
The field comments for ApiResponse<T> are already very clear: code is a stable business code, message is a human-oriented prompt, data is success data or structured error details, traceId is a link identifier, and timestamp is the server response time.
Here, traceId needs special emphasis. When the frontend reports an error, it can often only say "the API reported an error." If the response carries a traceId, the frontend can copy it to the backend, and the backend can find the corresponding request in the logs. This is a design that significantly improves frontend-backend debugging efficiency. When you do full-stack development in the future, you should also develop the habit of establishing correlation IDs for errors, logs, and responses.
timestamp uses Instant, which is a UTC point in time. The frontend may need to convert it to the local timezone for display. Don't pass ambiguous time strings between the backend and frontend, otherwise timezone issues can easily arise. Subsequent orders, payments, and timeout order cancellations will all involve time, so establishing time awareness early is important.
6.10 Reading GlobalExceptionHandler
The GlobalExceptionHandler class has:
@RestControllerAdvice
public class GlobalExceptionHandler {
...
}
@RestControllerAdvice can be understood as a "global Controller exception interceptor." It does not handle the normal business success path, but handles exceptions thrown during Controller or parameter binding processes.
Currently, it handles several types of errors:
MethodArgumentNotValidException:@Validvalidation failure;HttpMessageNotReadableException: JSON format error, field type mismatch, enum conversion failure;BusinessException: Expected business failure;NoResourceFoundException: Requested path does not exist;Exception: Catch-all for unknown exceptions.
This is very similar to a frontend Axios response interceptor. The frontend interceptor uniformly converts backend responses into pop-ups, login redirects, and error prompts; the backend global exception handler uniformly converts Java exceptions into HTTP status codes and ApiResponse. Both sides are doing the same thing: making error handling centralized, stable, and maintainable.
The most important part is the final catch-all exception handling. It writes the detailed stack trace to the server log but only returns a generic INTERNAL_ERROR to the outside. This is a security design. Don't return database SQL, stack trace paths, or internal class names to the frontend user. The frontend needs an error prompt and a traceId; the backend log saves the detailed troubleshooting information.
7. How to Run Locally / Verify with curl
7.1 Start the Service
If you haven't prepared MySQL and Redis yet, you can first use the local profile mentioned in Chapter 2:
cd backend
mvn -pl service -am spring-boot:run -Dspring-boot.run.profiles=local
If you are using real development dependencies:
docker compose -f docker-compose.dev.yml up -d mysql redis
cd backend
mvn -pl service -am spring-boot:run
The following curl examples use the default port 8080. If you use the debug profile, please change the port to 8081.
7.2 Verify Public Product List GET Query
curl 'http://localhost:8080/api/products?pageNum=1&pageSize=10'
You should focus on the shape of the response, not the specific number of products:
{
"code": "SUCCESS",
"message": "Operation successful",
"data": {
"pageNum": 1,
"pageSize": 10,
"total": 0,
"pages": 0,
"records": []
},
"traceId": "...",
"timestamp": "..."
}
Seed data may differ across environments, so total and records might not be exactly the same. What you need to verify is: the path is hit, parameters are bound successfully, the unified envelope is returned, and the pagination structure exists.
7.3 Verify Product Detail Path Parameter
curl 'http://localhost:8080/api/products/1'
If the product exists and is listed, you will get SUCCESS and product details. If the product does not exist or is not listed, you might get a business error code. You don't need to understand the database query process immediately here, just know that 1 will be bound to the Controller's @PathVariable Long id.
You can also intentionally pass a non-number:
curl 'http://localhost:8080/api/products/abc'
This type of request usually fails during the parameter conversion phase. It helps you understand: before entering the Controller method, Spring has already done path matching, type conversion, parameter binding, and other work.
7.4 Verify Query Parameter Validation Failure
curl 'http://localhost:8080/api/products?pageNum=0&pageSize=200'
The expected result is an HTTP 400, and the response body is in the unified structure, with code similar to VALIDATION_ERROR, and data.fieldErrors containing field errors. The frontend can use fieldErrors to display errors in the pagination component, search form, or global prompt.
This verification is very suitable for frontend-to-backend developers to practice. You will find that backend parameter validation is not an if written inside the business method, but is completed by the framework and global exception handling before the Controller method executes.
7.5 Verify Create Product Body Validation Failure
The admin API might require login permissions; subsequent security chapters will explain how to carry a JWT. Even if you are not logged in currently, you can first understand the request shape:
curl -i -X POST 'http://localhost:8080/api/admin/products' \
-H 'Content-Type: application/json' \
-d '{"categoryId": 1, "title": "", "description": "demo"}'
If the request can enter the parameter validation phase, the empty title will trigger "Product title cannot be empty." If you are not logged in, you might first be intercepted by the security filter, returning 401 or 403. This precisely illustrates that the backend request chain is not just the Controller: security filters, parameter binding, validation, and business logic each have their own sequence.
7.6 Verify JSON Format Error
curl -i -X POST 'http://localhost:8080/api/admin/products' \
-H 'Content-Type: application/json' \
-d '{"categoryId": 1, "title": "demo",}'
The trailing comma makes it invalid JSON. It will trigger a JSON parsing error, corresponding to GlobalExceptionHandler.handleMalformedJson(...). Frontend developers can understand it as: the request hasn't formed a valid Java object yet, so it won't enter the business method.
7.7 Verify Non-existent Path
curl -i 'http://localhost:8080/api/not-exists'
The expected result is a 404, wrapped into an ApiResponse by the unified exception handling. This is more suitable for front-end separation projects than Spring's default error page, because the frontend can handle errors with unified logic instead of parsing a piece of HTML or unstable JSON.
8. Common Errors
Error 1: Only Looking at the Method Path, Forgetting the Class-level Path
Seeing @GetMapping("/{id}") and requesting /1 will, of course, result in a 404. The full path must include the class-level @RequestMapping("/api/products"), so it should be /api/products/1.
Error 2: Incorrectly Using a JSON Body in a GET Request
List queries should typically use query parameters. If you use a GET body, many clients, proxies, and gateways will not handle it as you expect. The current ProductQueryRequest is bound to query parameters, not @RequestBody.
Error 3: Forgetting Content-Type: application/json for a POST Body
If the request body is JSON but Content-Type is not correctly set, the backend may not be able to parse it as JSON. When verifying POST with curl, it's recommended to explicitly add:
-H 'Content-Type: application/json'
Error 4: Using a Request DTO as an Entity
Creating a product only allows passing categoryId, title, description, which doesn't mean the product table only has these fields. The Request DTO is an external input whitelist, the Entity is a database mapping, and the Response DTO is an external output contract. The three cannot be mixed into one class.
Error 5: Thinking Backend Validation is Unnecessary if the Frontend Validates
Frontend validation is only for user experience. Anyone can bypass the page and call the API directly. The backend must validate required fields, length, numeric ranges, enum values, permissions, and business states.
Error 6: Parsing message for Business Logic Decisions
The current project explicitly uses code as the stable business code, and message is a human-oriented prompt. The frontend should judge redirects, pop-ups, and form errors based on code, not by matching Chinese text.
Error 7: Returning 200 for All Errors
Some projects stuff all errors into the response body and always use HTTP status code 200. This degrades debugging and monitoring quality. The current project returns 400 for parameter errors, 404 for non-existent paths, 500 for unknown errors, and 201 for successful creation, which better aligns with HTTP semantics.
Error 8: Writing Too Much Business Logic in the Controller
The Controller should be thin. It is responsible for the HTTP contract and calling the business entry point, and should not be piled with complex inventory, transactions, caching, and SQL. The current project uses IProductFacade to handle the business entry point; subsequent chapters will continue reading down into Facade, DbService, and Mapper.
Error 9: Not Knowing if an Error Occurred Before or After the Controller
If JSON parsing fails, type conversion fails, or @Valid validation fails, the request might never enter the Controller method. When debugging, don't just set breakpoints in the business method; also look at the HTTP status code, response body, and global exception handling logs.
Error 10: Mixing Public and Admin APIs
/api/products is a public product entry point accessible to anonymous users, while /api/admin/products is an admin product management entry point. They have different paths, permissions, and business constraints. Don't merge them into one API just because they both operate on products.
9. Chapter Exercises
Exercise 1: Deduce the Controller Method from a URL
Given the request:
GET /api/products/12
Please answer:
- Which Controller class is hit?
- Which method is hit?
- Which Java parameter will
12be bound to? - What is the type of the returned
data?
Reference direction: Look at the class-level @RequestMapping and method-level @GetMapping("/{id}") of ProductController.
Exercise 2: Deduce a curl Command from a Controller Method
See the method:
@GetMapping
public ApiResponse<PageResponse<ProductSummaryResponse>> queryProducts(
@Valid @ParameterObject ProductQueryRequest request,
HttpServletRequest servletRequest
) { ... }
Please write a curl request with pageNum, pageSize, and keyword. Note that it is a GET query, not a JSON body.
Exercise 3: Identify the Fields the Frontend is Allowed to Submit for Creating a Product
Open:
backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductCreateRequest.java
Answer:
- Which fields are required?
- Which fields have length limits?
- Why can't the frontend pass
id,createdBy,createdAt?
Exercise 4: Observe the Parameter Validation Error Structure
After starting the service, request:
curl -i 'http://localhost:8080/api/products?pageNum=0&pageSize=200'
Record:
- What is the HTTP status code?
- What is the
codein the response? - What fields are in
fieldErrors? - Does
traceIdexist?
Exercise 5: Draw the Create Product Request Chain
In your own words, draw the chain for:
POST /api/admin/products
from the JSON body to ProductCreateRequest, then to productFacade.createProduct(request), and finally to ApiResponse<ProductDetailResponse>. You can use Mermaid or draw it by hand.
Exercise 6: Distinguish the Three Models
Please classify the following fields into Request DTO, Response DTO, Entity, or "should not be submitted by the frontend":
title;description;categoryName;createdBy;createdAt;id;subtitle;deleted;version.
This exercise has no single answer, as it depends on the specific API scenario. But your judgment must be able to explain "who produces this field, who consumes it, and whether it is trusted."
10. Summary of This Chapter
In this chapter, we truly entered the backend API layer. You should now know:
- A Controller is the Web layer entry point for HTTP requests entering the business system;
@RestControllermakes the method return value the JSON response body;- Class-level
@RequestMappingand method-level@GetMapping,@PostMapping,@PatchMappingtogether form the complete path; - GET list queries typically use query parameters, which can be bound to
ProductQueryRequest; - Dynamic URL segments are bound with
@PathVariable, e.g.,/api/products/{id}; - Complex submitted data for POST / PATCH typically uses
@RequestBodyto bind JSON; @Validtriggers Bean Validation checks on the Request DTO;- A Request DTO is an input whitelist, a Response DTO is an output contract, and an Entity is a database mapping;
ApiResponse<T>unifiescode,message,data,traceId,timestamp;ResponseEntitycan control the HTTP status code, e.g., returning 201 for a successful creation;GlobalExceptionHandlerconverts validation errors, JSON errors, business errors, 404s, and unknown exceptions into a unified response.
For a frontend engineer, the most important change in this chapter is: you are no longer just "the person calling the API," but are beginning to become "the person defining the API contract." A good backend API is not just one that can run through, but one where the path, method, parameter location, validation rules, response structure, and error codes are all stable, clear, and debuggable.
11. Next Chapter Preview
The next chapter is Part 4: MySQL and SQL Basics: From Frontend State to Server-Side Persistent Data.
We will first fill in your missing database fundamentals, then return to the current project's sql/01_schema.sql, progressively covering:
- What exactly are databases, tables, rows, and columns;
- What problems do primary keys, foreign keys, unique constraints, and indexes solve respectively;
- Why the backend can't just keep data in memory;
- How tables like products, categories, SKUs, and orders are roughly related;
- Why
created_at,updated_at, and status fields are important; - The differences between frontend state, localStorage, and backend MySQL persistence;
- How to query the current project's data using the most basic SQL.
After studying the next chapter, you will be able to trace "the API returns JSON" further down to "which table does the data come from, what do the fields mean, and why was it designed this way."