A Single Subtitle Field Touches Every Layer of a Backend
15 | (Frontend to Full-Stack) Understanding the Complete Backend Delivery Chain from a Single Subtitle Field
GitHub Repository: https://github.com/2530622506/fullstack-mall
04 | (Frontend to Full-Stack) Why Is Frontend State Not Enough? From Page Data to MySQL Persistence
This article is aimed at frontend engineers. It uses the current
fullstack-mallbackend project to explain a small, complete requirement: an administrator modifying a product subtitle. The frontend code in the text serves only as "frontend-side illustrative code" for analogizing form submission and API calls. The corresponding reference document isdocs/admin-update-product-subtitle-guide.md. The corresponding real code includesProductSubtitleUpdateRequest,ProductAdminController.updateSubtitle,ProductFacade.updateSubtitle,ProductDbService.updateSubtitle,ProductDetailResponse,ProductEntity,SecurityConfig,sql/01_schema.sql, and the cache invalidation logic.
1. What Problem Does This Solve
Many frontend engineers transitioning to the backend underestimate the complexity of "adding a field" when they first take on a backend requirement. On a frontend page, adding a subtitle might just mean: one more input in a form, one more column in a list, and one more line of display on a detail page. But in the backend, a single field must pass through the database, persistence objects, request DTOs, response DTOs, business contracts, business implementations, HTTP entry points, permission controls, parameter validation, cache invalidation, test data, API verification, and debugging processes.
This article uses the "administrator modifies product subtitle" feature in the current project as a practical case study. The business requirements for this feature are:
- Only administrators can modify it;
- The subtitle is required and can be a maximum of 100 characters;
- A clear business error is returned if the product does not exist;
- After a successful modification, the new value is visible in the public details;
- A Redis failure must not roll back a successful MySQL modification;
- The old product detail cache must be invalidated after a successful modification.
From a frontend perspective, the requirement looks like this:
// Frontend-side illustrative code: Submitting a subtitle from the admin product edit page. This is not the real frontend source code of this repository.
async function submitSubtitle(productId: number, subtitle: string) {
await request.patch(`/api/admin/products/${productId}/subtitle`, { subtitle })
showToast('Modification successful')
}
But the backend cannot just look at this single line of request. The backend must answer: Which Controller does this API endpoint go in? Can a regular user call it? Who blocks an empty string? Who blocks an overly long one? Does the database field exist? Does the Entity have it? Does the Response have it? Is the cache deleted after modification? What if the Redis deletion fails? Stringing these questions together forms the complete backend development process.
2. Drawing an Analogy with Frontend Knowledge
You can analogize this requirement to a complete frontend change, from the API contract to the page state:
| Frontend Change Point | Backend Corresponding Change Point | Project File |
|---|---|---|
Form field subtitle |
Request DTO field | ProductSubtitleUpdateRequest |
| Adding a field to a TypeScript interface | Adding a field to a Response DTO | ProductDetailResponse |
| Form validation required / maxLength | @NotBlank / @Size |
ProductSubtitleUpdateRequest |
| Adding a method to the API client | Adding a method to the Facade contract | IProductFacade.updateSubtitle |
| Request sent to an admin route | Controller adds a PATCH endpoint | ProductAdminController |
| Page only allows admin entry | Security requires ADMIN for /api/admin/** |
SecurityConfig |
| Invalidate query after a successful mutation | Delete Redis cache after a successful MySQL operation | ProductFacade.updateSubtitle |
| Page re-fetches details | The next public detail request falls back to MySQL | ProductDetailCacheService |
Frontend developers should pay special attention: the backend's "API contract" is more stable than frontend component props. If a frontend component field is wrong, it affects one page; if a backend Response field is wrong, all consumers are affected. Backend database fields are even more of a long-term asset; once online, they cannot be arbitrarily deleted or have their types changed. Therefore, this article is not just about @PatchMapping, but about the flow of a single field through all layers.
flowchart LR
Form[Admin Form subtitle] --> Request[ProductSubtitleUpdateRequest]
Request --> Controller[ProductAdminController]
Controller --> Facade[IProductFacade and ProductFacade]
Facade --> DbService[ProductDbService]
DbService --> MySQL[(mall_product.subtitle)]
MySQL --> Entity[ProductEntity]
Entity --> Response[ProductDetailResponse]
Response --> Page[Frontend displays new subtitle]
3. Explanation of Core Backend Concepts
3.1 The Database Field Is the Source of Truth
Where is the subtitle ultimately saved? The answer is not a Java field, nor Redis, nor frontend state, but mall_product.subtitle. This project has added this field in both the formal SQL and the test SQL:
subtitle VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'Product Subtitle'
Why NOT NULL DEFAULT ''? Because old data or old creation logic might not have a subtitle. If you directly add NOT NULL without a default value, inserting a product might fail. The default empty string is a database fallback; the "administrator modifies subtitle" API itself uses @NotBlank to ensure the user's submission cannot be empty. The database fallback and the API validation are not substitutes for each other but protections at different boundaries.
Frontend developers can understand the database default value as a fallback for an API response field, but should not treat it as user input validation. Input validation should be done at the request entry point, with database constraints serving as the last line of defense.
3.2 The Request DTO Handles Entry Validation
ProductSubtitleUpdateRequest is very small:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductSubtitleUpdateRequest {
@NotBlank(message = "Product subtitle cannot be empty")
@Size(max=100,message = "Product subtitle cannot exceed 100 characters")
private String subtitle;
}
It does two things: first, it tells the Controller what shape the JSON request body should be; second, it declares field rules through Bean Validation. @NotBlank rejects null, empty strings, and strings containing only whitespace; @Size(max=100) rejects strings longer than 100 characters.
In the frontend-side illustrative code, you might also write:
// Frontend-side illustrative code: Frontend validation can improve the experience but cannot replace backend validation.
if (!subtitle.trim()) {
throw new Error('Product subtitle cannot be empty')
}
if (subtitle.length > 100) {
throw new Error('Product subtitle cannot exceed 100 characters')
}
But the backend cannot trust that frontend validation was definitely executed. A user can use curl, Postman, scripts, or a tampered page to directly request the API. Therefore, @Valid @RequestBody ProductSubtitleUpdateRequest request is a necessary defense at the backend entry point.
3.3 The Controller Only Serves as the HTTP Entry Point, Not for Writing Business Details
The newly added endpoint in ProductAdminController is:
@PatchMapping("/{id}/subtitle")
@Operation(summary = "Modify product subtitle")
public ApiResponse<ProductDetailResponse> updateSubtitle(
@PathVariable Long id,
@Valid @RequestBody ProductSubtitleUpdateRequest request,
HttpServletRequest servletRequest
) {
ProductDetailResponse response = productFacade.updateSubtitle(id, request);
return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}
This code reflects the project's style: the Controller handles the URL, HTTP method, path parameters, request body validation, and unified response; the real business is delegated to the Facade. The Controller does not directly write MyBatis-Plus, does not directly operate Redis, and does not judge whether a product exists by itself. Frontend developers can analogize the Controller to a route entry point, while the Facade is the business composition function.
sequenceDiagram
participant FE as Admin Product Edit Page
participant C as ProductAdminController
participant F as ProductFacade
participant D as ProductDbService
participant R as Redis Cache
FE->>C: PATCH /api/admin/products/{id}/subtitle
C->>C: @Valid validates request body
C->>F: updateSubtitle(id, request)
F->>F: trim subtitle
F->>D: updateSubtitle(id, subtitle)
D->>D: UPDATE mall_product
F->>R: evict product detail cache
F-->>C: ProductDetailResponse
C-->>FE: ApiResponse SUCCESS
3.4 The Facade Is the Business Orchestration Layer
What ProductFacade.updateSubtitle does is very clear:
String subtitle = request.getSubtitle().trim();removes leading and trailing whitespace;productDbService.updateSubtitle(productId, subtitle);modifies the database;productDetailCacheService.evict(productId);deletes the public detail cache;- Queries the category and assembles the
ProductDetailResponseto return.
Why is trim placed in the Facade and not the Controller? Because trim is business input standardization, not part of the HTTP protocol itself. The Controller should be thin; the Facade can carry business rules like "how to handle user input before saving."
Why must evict be called after a successful modification? Because the public product detail cache also contains the subtitle. If the cache is not deleted, after a successful admin modification, anonymous users accessing /api/products/1 might still read the old subtitle. This is the same idea as invalidating invalidateQueries(['product', id]) after a successful mutation in frontend React Query.
3.5 DbService Encapsulates Database Updates
ProductDbService.updateSubtitle uses LambdaUpdateWrapper:
LambdaUpdateWrapper<ProductEntity> wrapper=Wrappers.lambdaUpdate(ProductEntity.class)
.eq(ProductEntity::getId,productId)
.set(ProductEntity::getSubtitle,subtitle)
.set(ProductEntity::getUpdatedAt,now);
int affectedRows = baseMapper.update(null, wrapper);
if (affectedRows == 0) {
throw productNotFound();
}
return requireById(productId);
There are 3 key points here. First, the update condition is id = productId, modifying only the target product. Second, updatedAt is updated simultaneously, allowing the frontend and investigators to see when the data changed. Third, affectedRows is checked to determine if the product exists. An affected row count of 0 means there is no such id, so PRODUCT_NOT_FOUND is returned.
Frontend developers easily understand "a successful modification API call" as "no exception means success." The backend is more rigorous: the number of affected rows from a database UPDATE is also a basis for business judgment. Especially in orders, payments, and inventory, the affected row count can tell you whether you've won the right to transition a state; in this simple subtitle requirement, it tells you whether the product exists.
3.6 Permissions Come from Path Rules
This endpoint is placed under /api/admin/products/{id}/subtitle, so it hits the rule in SecurityConfig:
.requestMatchers("/api/users/**", "/api/admin/**").hasRole("ADMIN")
This means: an unauthenticated user accessing it will get a 401, a regular user will get a 403, and only an administrator will enter the Controller. There is no need to manually write "reject if not an administrator" on the Controller method, because the path-level rule already covers /api/admin/**.
flowchart TD
A["PATCH /api/admin/products/1/subtitle"] --> B{"Has valid JWT?"}
B -->|No| C[401 UNAUTHORIZED]
B -->|Yes| D{Has ADMIN role?}
D -->|No| E[403 FORBIDDEN]
D -->|Yes| F[Enter ProductAdminController]
4. Corresponding Files in This Project
| Layer | File | Role |
|---|---|---|
| Database Structure | sql/01_schema.sql, test schema |
Adds mall_product.subtitle |
| Seed Data | sql/02_seed.sql, test data |
Prepares subtitles for demo products |
| Entity | ProductEntity.java |
Java persistence object adds subtitle |
| Request | ProductSubtitleUpdateRequest.java |
Receives and validates the new subtitle submitted by the admin |
| Response | ProductDetailResponse.java |
Public details return subtitle |
| Facade Contract | IProductFacade.java |
Declares the updateSubtitle use case |
| HTTP Entry Point | ProductAdminController.java |
Exposes PATCH /api/admin/products/{id}/subtitle |
| Business Orchestration | ProductFacade.java |
trim, update DB, delete cache, assemble Response |
| Data Access | ProductDbService.java |
Executes UPDATE mall_product SET subtitle |
| Permissions | SecurityConfig.java |
/api/admin/** requires ADMIN |
| Cache | ProductDetailCacheService.java |
Deletes public detail cache after successful modification |
| Documentation | docs/admin-update-product-subtitle-guide.md |
A more detailed, step-by-step development guide |
Complete call diagram:
flowchart TD
SQL[mall_product.subtitle] --> Entity[ProductEntity.subtitle]
Entity --> Db[ProductDbService.updateSubtitle]
Req[ProductSubtitleUpdateRequest] --> Controller[ProductAdminController.updateSubtitle]
Controller --> Facade[ProductFacade.updateSubtitle]
Facade --> Db
Db --> Entity
Facade --> Cache[ProductDetailCacheService.evict]
Facade --> Resp[ProductDetailResponse.subtitle]
Resp --> Client[Admin page and public details]
5. Understanding This Requirement in the Real Development Order
5.1 Step 1: Confirm the Data Table
When a backend developer encounters a field requirement, the first step is not to write a Controller, but to confirm whether this field needs to be persisted. If the subtitle is only for temporary display, it doesn't need to be stored in the database; but a product subtitle is a long-term product attribute, so it must go into mall_product. The formal schema, test schema, and seed data must all be synchronized, otherwise the local runtime and test environments will be inconsistent.
This is different from a frontend developer just changing a piece of mock data. The backend has multiple runtime environments: local MySQL, H2 testing, MySQL Testcontainers, and SQL initialization scripts. If a field is only changed in one place, another environment might fail tests or fail to start.
5.2 Step 2: Synchronize Entity and Response
ProductEntity corresponds to the database table. MyBatis-Plus by default maps underscore fields to camelCase fields. This project has map-underscore-to-camel-case enabled, so the subtitle field can be directly written as private String subtitle;.
ProductDetailResponse is the external JSON contract. Just because a database has a field doesn't mean the frontend can see it; it must be passed out in the Response DTO and the toDetailResponse method. Many problems where "the API is changed but the page has no data" occur because only the Entity was changed, forgetting to change the Response or the assembly method.
flowchart LR
DB[Database Field] --> Entity[Entity Field]
Entity --> Mapper[MyBatis Plus Mapping]
Mapper --> Facade[Facade Assembly]
Facade --> Response[Response DTO]
Response --> JSON[API JSON]
JSON --> UI[Page Display]
5.3 Step 3: Request DTO and Validation
Updating a subtitle is not about directly receiving a Map<String,Object>, nor receiving a String subtitle in the Controller. The project style is to define a Request DTO: ProductSubtitleUpdateRequest. This makes the Swagger documentation clear, the validation rules clear, and error returns unified.
A validation failure will not enter the Facade. GlobalExceptionHandler.handleValidation converts MethodArgumentNotValidException into a unified response with the business code VALIDATION_ERROR and returns a list of field errors. When the frontend receives this error, it should locate the subtitle form item based on fieldErrors, not parse the Chinese message.
5.4 Step 4: Facade Contract First
IProductFacade in backend/contract is the module's external use-case contract. After adding updateSubtitle, ProductFacade implements it, and the Controller injects the interface rather than directly depending on the implementation class. This design is somewhat similar to the frontend principle of "pages depending on API client types, not piecing together URLs everywhere."
The benefit of a contract-first approach is clear boundaries: the Controller knows there is a "modify subtitle" use case, but doesn't know how this use case internally queries the database or deletes the cache. If the internal implementation changes later, the Controller doesn't need to follow suit.
5.5 Step 5: Choose the Right Path for the Controller
This requirement is an administrator operation, so the endpoint is placed in ProductAdminController with the path /api/admin/products/{id}/subtitle. Do not put it in the public ProductController. The public Controller is open to anonymous users; if a write endpoint is accidentally placed there, it becomes a security vulnerability.
The path is part of the permission model. Frontend developers usually use route guards to protect admin pages, but the backend cannot just trust that the page has no entry point. Even if the frontend doesn't display a button, a malicious user can still directly request the API. The backend must perform real authorization on the URL through SecurityConfig.
5.6 Step 6: Cache Invalidation
The subtitle will appear in the public detail cache, so after a successful write, mall:product:detail:v1:{id} must be deleted. The project's comments are very clear: delete the cache after the MySQL modification succeeds; if the Redis deletion fails, ProductDetailCacheService internally swallows the exception and logs it.
Why not roll back if the deletion fails? Because Redis is a performance layer; a successful database write must not be turned into a business failure. In the worst-case scenario, the old cache will persist until its TTL expires; a better troubleshooting method is to look for product_detail_cache event=DEGRADED operation=EVICT in the logs and then manually delete the corresponding key.
6. Local Execution and curl Verification
6.1 Start the Service and Log in as Admin
cd backend
mvn -Dmaven.repo.local=$PWD/.m2-repository -pl service -am spring-boot:run
Login:
curl -sS -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"Admin123456"}'
ADMIN_TOKEN='Paste accessToken'
6.2 View Public Details Before Modification
curl -sS http://localhost:8080/api/products/1
Pay attention to data.subtitle in the response. If you want to observe the cache, you can request it twice first, then check Redis:
docker exec fullstack-mall-redis redis-cli GET mall:product:detail:v1:1
6.3 Admin Modifies the Subtitle
curl -i -X PATCH http://localhost:8080/api/admin/products/1/subtitle \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"subtitle":" New Subtitle "}'
Expected: HTTP 200, code=SUCCESS, the returned data.subtitle is the trimmed New Subtitle.
6.4 Check Public Details Again
curl -sS http://localhost:8080/api/products/1
If the cache invalidation is correct, the public details should show the new subtitle. You can also directly query MySQL:
docker exec fullstack-mall-mysql mysql -umall -pmall123 fullstack_mall \
-e "SELECT id, title, subtitle FROM mall_product WHERE id = 1;"
6.5 Verify Validation Failures
Empty subtitle:
curl -i -X PATCH http://localhost:8080/api/admin/products/1/subtitle \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"subtitle":" "}'
Expected: HTTP 400, code=VALIDATION_ERROR, field errors include subtitle.
Overly long subtitle:
LONG_SUBTITLE=$(python3 - <<'PY'
print('很' * 101)
PY
)
curl -i -X PATCH http://localhost:8080/api/admin/products/1/subtitle \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"subtitle\":\"$LONG_SUBTITLE\"}"
Expected is also VALIDATION_ERROR.
6.6 Verify Permissions
Not logged in:
curl -i -X PATCH http://localhost:8080/api/admin/products/1/subtitle \
-H 'Content-Type: application/json' \
-d '{"subtitle":"Attempt without login"}'
Call after logging in as a regular user:
USER_TOKEN='Regular user accessToken'
curl -i -X PATCH http://localhost:8080/api/admin/products/1/subtitle \
-H "Authorization: Bearer $USER_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"subtitle":"Attempt by regular user"}'
Expected are 401 and 403 respectively.
6.7 Verify Non-Existent Product
curl -i -X PATCH http://localhost:8080/api/admin/products/999999/subtitle \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"subtitle":"New Subtitle"}'
Expected: HTTP 404, business code PRODUCT_NOT_FOUND. The reason is that affectedRows in ProductDbService.updateSubtitle is 0.
7. Common Mistakes
7.1 Only Changing the Controller, Not the Database
The Controller can receive the request, but that doesn't mean the data can be saved. If mall_product doesn't have a subtitle field, the Entity and SQL are incomplete, ultimately leading to either a startup failure or an ineffective update.
7.2 Only Changing the Entity, Not the Response
The database does have the subtitle, but the API JSON doesn't. The frontend will think the backend hasn't finished the change. Check ProductDetailResponse and ProductFacade.toDetailResponse.
7.3 Placing an Admin Endpoint in a Public Controller
This is a permission error. Write operations should be placed under /api/admin/**, protected by the admin rules in SecurityConfig.
7.4 Replacing Backend Validation with Frontend Validation
Frontend validation can only improve the experience; it cannot provide a security guarantee. The backend must have @NotBlank and @Size.
7.5 Forgetting @Valid
Having annotations on the Request DTO is not enough; the Controller parameter must have @Valid. Otherwise, Bean Validation will not intercept the request body.
7.6 Not Deleting the Cache After a Successful Modification
This leads to MySQL having the new value while the public details still return the old value. When troubleshooting, check MySQL first, then the Redis key.
7.7 Rolling Back the Business If Redis Deletion Fails
Do not let a cache deletion failure roll back a successful product modification. This project chooses to log and rely on TTL as a fallback.
7.8 Forgetting to Update Test Data and Test Schema
The formal SQL was changed, but the test SQL wasn't; unit tests or integration tests will fail. A field requirement must synchronize all initialization scripts.
7.9 Using Map to Receive Requests
Map makes Swagger, validation, field names, and types all ambiguous. The project style is for every business request to have a clear Request class.
8. Recommended Debugging Order for This Requirement
flowchart TD
A[Subtitle modification abnormal] --> B{HTTP Status Code}
B -->|401| C[Check Authorization Bearer Token]
B -->|403| D[Check if user role is ADMIN]
B -->|400| E[Check if subtitle is empty or too long]
B -->|404| F[Check if productId exists]
B -->|200 but page shows old value| G[Check MySQL subtitle]
G --> H{Is MySQL the new value?}
H -->|No| I[Breakpoint ProductDbService.updateSubtitle]
H -->|Yes| J[Check Redis detail cache]
J --> K[Check ProductDetailCacheService.evict logs]
K --> L[Re-request public details]
When debugging the backend, don't just stare at the Controller. You should check layer by layer along the chain: did the request enter the Controller, did the Request pass validation, did the Facade trim, was the DbService affected row count 1, is MySQL the new value, was the Redis key deleted, does the Response contain subtitle, and did the public details fall back to the source.
9. Chapter Exercises
- Draw the complete path of
subtitlefrom the JSON request body to MySQL and back to the Response JSON. - Explain why
ProductSubtitleUpdateRequestuses both@NotBlankand@Size. - Find
SecurityConfigand explain why a regular user gets a 403. - After modifying the subtitle, manually use the Redis CLI to check if the detail cache was deleted.
- If the API returns 200 but the public details still show the old subtitle, write out the troubleshooting steps according to this article's debugging diagram.
- Compare frontend React Query's
invalidateQuerieswith the backend'sproductDetailCacheService.evict, explaining their similarities and differences. - Think about which layers would need modification if a "product main image URL" were to be added.
- Read
docs/admin-update-product-subtitle-guide.mdand organize the manual verification commands into your own checklist.
10. Next Chapter Preview: Debugging Methods and Common Pitfalls for Frontend-to-Backend Transition
Up to this point, we've walked through basic concepts, products, SKUs, shopping carts, orders, payments, Redis caching, and a real field requirement. The next article will summarize a methodology: How can frontend engineers migrate their browser DevTools troubleshooting habits to the backend? How to distinguish 401, 403, 404, 409, and 500? How to locate problems using traceId, unified responses, logs, curl, MySQL, Redis, and tests? How to avoid blind debugging based on "I think the code is fine"?
11. Summary of This Chapter
A small requirement like "administrator modifies product subtitle" actually cuts through the database, Entity, Request, Response, Facade contract, Controller, Security, DbService, cache, logs, and verification commands. When frontend developers transition to the backend, don't just focus on a single annotation; establish a holistic view of "field flow": where does the field come from, where is it validated, where is it saved, how is it returned, who has permission to modify it, which caches must be invalidated after modification, and what error code is returned on failure.
If you can independently explain why the endpoint is placed at /api/admin/products/{id}/subtitle, why @Valid must be present, why evict is needed after a successful modification, and why a Redis deletion failure does not roll back MySQL, it means you already possess the basic thinking required to develop small backend requirements. The next article will organize this thinking into a debugging and troubleshooting methodology.
Appendix A: Breaking Down "One Field" into a Backend Engineering Checklist
To enable you to independently handle similar requirements in the future, here is a checklist abstracted from the subtitle requirement. First question: Does this field need to be persisted? If it's just a temporary calculation result, it can be placed only in the Response; if it's a long-term business attribute, it must go into the database. The subtitle is clearly a long-term attribute, so mall_product needs to be changed.
Second question: Who fills in this field? If filled by a frontend user, a Request DTO and Bean Validation are needed; if generated by the server, like createdAt, createdBy, or an order number, the frontend should not be allowed to pass it. The subtitle is filled by an admin, so ProductSubtitleUpdateRequest is needed; the product creator is determined by the current login state, so the frontend cannot pass createdBy.
Third question: To whom is this field visible? If the public product details need to display it, ProductDetailResponse must be changed; if it's only for the admin panel, you might need to distinguish between an admin response and a public response. In the current project, the public details also display the subtitle, so ProductDetailResponse carries subtitle.
Fourth question: Who can modify it? The subtitle can only be modified by an admin, so the endpoint is placed under /api/admin/**. If in the future regular sellers can also modify their own products, the permission model cannot rely solely on ADMIN but must add a "product ownership" check.
Fifth question: Which caches are affected by the modification? As long as this field appears in a cached Response, the corresponding key must be invalidated after modification. The subtitle appears in the product detail cache, so evict(productId) is needed.
Sixth question: How is failure returned? An empty field is a 400 VALIDATION_ERROR, a non-existent product is a 404 PRODUCT_NOT_FOUND, not logged in is a 401, a regular user is a 403, and a Redis deletion failure does not change the main business success result. A backend API does not just return true or false; error codes are a collaboration contract between frontend and backend.
flowchart TD
A[New or modify field requirement] --> B{Persist?}
B -->|Yes| C[Modify SQL and Entity]
B -->|No| D[Only modify calculation and Response]
C --> E{Who fills?}
E -->|User input| F[Request DTO and validation]
E -->|Server generated| G[Facade or Service assignment]
F --> H{Who sees?}
H --> I[Response DTO]
I --> J{Who can modify?}
J --> K[Security and Controller path]
K --> L{Does it affect cache?}
L -->|Yes| M[evict after successful write]
L -->|No| N[No cache handling needed]
This checklist can be reused for many requirements: product main image, product condition, seller notes, category sorting, user nickname, etc. Every time you do one, you should scan it from six angles: data, contract, permissions, cache, errors, and testing.
Appendix B: How to Add Automated Tests for the Subtitle Requirement
The current series mainly generates documentation, but as a backend engineer, you should know how this requirement should be tested. At a minimum, 6 types of tests can be added.
The first type is a successful modification by an admin. Prepare an admin token, call PATCH /api/admin/products/1/subtitle, assert HTTP 200, code=SUCCESS, data.subtitle equals the trimmed new value, and then check ProductDbService.getById(1L) to confirm the database has been updated.
The second type is unauthenticated and regular users. An unauthenticated call should return 401 UNAUTHORIZED; a call with a regular user token should return 403 FORBIDDEN. This test proves the API path is indeed protected by /api/admin/**.
The third type is parameter validation. An empty string, whitespace only, and strings exceeding 100 characters should all return 400 VALIDATION_ERROR, and the field errors should include subtitle. This test proves @Valid and the DTO annotations are effective.
The fourth type is a non-existent product. An admin calling /api/admin/products/999999/subtitle should return 404 PRODUCT_NOT_FOUND. This test proves ProductDbService.updateSubtitle correctly checks the UPDATE affected row count.
The fifth type is cache invalidation. First, make the product detail cache hit, then modify the subtitle, assert that productDetailCacheService.evict was called, or in a higher-level test, confirm that the next public detail request returns the new value. This test proves that post-write invalidation was not missed.
The sixth type is that a Redis deletion failure does not affect the main business. You can mock productDetailCacheService.evict to throw an exception, or verify inside the cache service that the delete exception is swallowed and logged. Since the current ProductDetailCacheService.evict catches exceptions itself, the Facade doesn't need to catch. The purpose of the test is not to actually crash Redis, but to prove the business semantic: after a MySQL success, a cache deletion failure does not turn the API into a failure.
Test names can be written like this:
adminCanUpdateProductSubtitleAndEvictsDetailCache
anonymousUserCannotUpdateProductSubtitle
normalUserCannotUpdateProductSubtitle
rejectsBlankOrTooLongSubtitle
updateSubtitleReturns404WhenProductDoesNotExist
redisEvictFailureDoesNotRollbackSubtitleUpdate
Test names should read like business rules, not implementation details. testPatchSubtitle carries too little information; six months later, you won't understand what it protects.
Appendix C: How to Review This Requirement During Code Review
If you, as a reviewer, look at someone else's submitted subtitle feature, don't just check if the API runs. You should check layer by layer.
Check the SQL: Is the field type reasonable? Is the length consistent with the validation? Is there a default value? Are the formal schema and test schema synchronized? Is the seed data synchronized?
Check the DTOs: Does the Request have explicit fields? Are @NotBlank and @Size used? Are the error messages user-facing? Is the use of Map avoided? Does the Response contain the fields the frontend needs? Are the field names stable?
Check the Controller: Is the path placed under admin? Does the HTTP method match the semantics? Is @Valid present? Does it return a unified ApiResponse? Does it avoid writing business details?
Check the Facade: Does it standardize input? Does it call the correct DbService? Does it delete the cache after a MySQL success? Does it avoid swallowing business exceptions? Does it return a complete Response?
Check the DbService: Are the update conditions correct? Is updatedAt updated? Is the affected row count checked? Is a non-existence converted into a project-unified business exception?
Check the Permissions: Does SecurityConfig already cover the path? Do tests prove that regular users cannot call it?
Check the Cache: Does this field appear in the cache? Is it invalidated after modification? Does the deletion failure conform to the project's fail-open design?
Check the Verification: Is there curl documentation or automated test coverage for success, validation failure, unauthenticated, insufficient permissions, non-existent product, and cache scenarios?
This review checklist embodies backend thinking: changing a field is not a local text replacement, but maintaining a cross-layer contract.
Appendix D: Boundary Division from Frontend Forms to Backend Validation
The goal of frontend form validation is experience: the user knows the subtitle cannot be empty before submitting, the button can be disabled, and the input field can show the remaining character count. The goal of backend validation is security and consistency: regardless of whether the request comes from a real page, curl, a script, or a malicious call, the same set of rules must be enforced.
So don't ask, "If the frontend already validated it, does the backend still need to?" The answer is always yes. A more precise question is: Which validations are done on both the frontend and backend, and which are only done on the backend? Things like required fields, length, and format can be done on both sides; things like the current user's identity, whether they are an admin, whether the product exists, whether the status allows modification, and whether inventory is sufficient must be based on the backend.
In the subtitle requirement, the frontend can check for empty values and length, and the backend checks again via @NotBlank and @Size. The frontend cannot decide "this user is an admin"; it can only decide whether to display a button based on user information returned by the backend; the real authorization is performed by Security. The frontend cannot decide "the product definitely exists," because the product might be deleted or delisted after the page is opened; the backend update must judge based on the database affected row count.
flowchart LR
A[Frontend Validation] --> B[Improve User Experience]
A --> C[Reduce Invalid Requests]
D[Backend Validation] --> E[Security Boundary]
D --> F[Unified Business Rules]
D --> G[Protect Database]
B --> H[Both Complement Each Other]
E --> H
Appendix E: If This Requirement Needs Further Expansion
Suppose the product team later proposes: subtitle modifications need to record an operation log, need to return the modifier, need to support batch modifications, and need regular operators to only modify products under their responsible categories. You will find that the architecture must continue to evolve.
An operation log means a new table, such as mall_product_audit_log. After the Facade updates the product, it must also insert a log, preferably within the same transaction. Returning the modifier means mall_product might need an updated_by column, filled by CurrentUserService.requireCurrentUserId. Batch modification means the Controller Request might become multiple product IDs, and the DbService must consider batch UPDATEs and partial failure strategies. Category permissions mean you cannot rely solely on /api/admin/**; you must also validate the category scope the current admin can manage in the business layer.
These expansions illustrate that simple requirements are suitable for laying a foundation with clear layering, while complex requirements will add transactions, auditing, permission models, and batch processing strategies on top of that layering. When frontend developers transition to the backend, they don't need to design for the most complex scenario from the start, but they should know which layers each change will affect.
Appendix F: Breaking "Modify a Field" into Backend Task Cards
In a real team, the product manager might only say one sentence: "The admin product list supports modifying the subtitle, and the frontend product detail page displays the latest subtitle." If you are a frontend developer, you might naturally break this down into a form input box, an API call, a success notification, and refreshing the list. But as a backend developer, you need to break it down into task cards closer to the system's facts. The first card is the data layer: does mall_product already have a subtitle field? What is the length, default value, whether it allows nulls, and how to handle historical data? The second card is the contract layer: what is the request body field called, what is the maximum length, and does the response reuse the product detail Response? The third card is the permission layer: who can call it, and what is returned for unauthenticated and non-admin users? The fourth card is the business layer: what if the product doesn't exist, what if the string is empty, and are leading/trailing spaces preserved? The fifth card is the cache layer: which caches need to be invalidated after a successful modification? The sixth card is the verification layer: what do unit tests, integration tests, and manual curl verification each cover?
This breakdown method looks slower than "adding a field," but it prevents rework. For example, if you write the Controller first and then discover the SQL has no field, the API definitely won't run; if you only change the database and Entity but not the Response, the frontend will never get the new value; if you forget permissions, a regular user might call the admin API; if you forget the cache, the public details will show the old subtitle. The value of backend development lies in identifying these hidden impacts all at once.
When writing this requirement into task cards, you can use this order: first confirm sql/01_schema.sql and ProductEntity; then confirm whether ProductDetailResponse includes subtitle; then add or check the validation of ProductSubtitleUpdateRequest; then expose the method in IProductFacade; then implement ProductDbService.updateSubtitle; finally, connect it to ProductAdminController and add tests. This order is consistent with the code reading order in this article, as it proceeds from the stable source of truth to the external entry point, reducing mid-way guesswork.
Appendix G: How to Align Backend Field Naming with Frontend Field Naming
The frontend is accustomed to using camelCase, such as subtitle, categoryId, createdAt; the database is accustomed to using snake_case, such as category_id, created_at. This project connects the two through MyBatis-Plus and entity fields: in Java it's subtitle, in SQL it's subtitle; in Java it's categoryId, in SQL it's category_id. This mapping seems simple, but it's easy to make mistakes when there are many fields.
Frontend developers doing full-stack work should develop a habit: for every field added, check along the chain of "database column name → Entity property name → Response field name → JSON field name → frontend variable name." If a layer intentionally has an inconsistency, write down the reason clearly. For example, the database might be called status_code, the Java enum field might be status, and the frontend might display "Listed / Delisted." This is reasonable because the semantics differ across layers. But if the database is called sub_title, Java is subtitle, the Response is productSubTitle, and the frontend is descTitle, future team troubleshooting will be very painful.
The subtitle field in this project is a good positive example: the table field is subtitle, the Entity field is subtitle, the Request field is subtitle, the Response field is subtitle, and the frontend-side illustrative code also submits { subtitle }. This makes the requirement have almost no translation cost from frontend to backend. When you design APIs in the future, don't create obscure field names just to "look backend-ish." The more stable the field names, the lower the cross-end collaboration cost.
Appendix H: How to Determine Which Response a Field Should Be Placed In
Another common question when modifying a subtitle is: should all product-related APIs return subtitle? The answer is not absolute. Backend Responses should serve specific scenarios, not directly expose the entire database row. Public product details need the subtitle because the page needs to display it; the admin edit page needs the subtitle because the admin needs to view and modify it; whether an order snapshot needs the subtitle depends on whether the order detail page requires preserving the marketing copy at the time of ordering. Whether the shopping cart list needs the subtitle also depends on the page display requirements.
Frontend developers can analogize this to component props. A product card component only needs the title, price, and image, not necessarily the full description; a detail page needs more fields; an admin edit form needs management fields like status, category, and creator. If all APIs return the complete product object, it will cause large responses, confusing field meanings, and easily lead the frontend to misuse fields it shouldn't depend on. Backend DTO design is essentially about designing different props contracts for different pages and business actions.
In the current project, it is reasonable for ProductDetailResponse to include subtitle because it describes the public details. Returning the same or a related Response after an admin successfully modifies it also helps the frontend refresh the view immediately. But if a new product list API is added in the future, you need to re-evaluate whether the list needs the subtitle; if it's just an admin table display, it can be returned; if it's a mobile homepage waterfall layout, it might not be returned for performance reasons. Don't automatically equate "the database has this field" with "all APIs must return this field."
Appendix I: Test Designs That Can Be Added for This Chapter
If continuing to refine this requirement, I suggest adding at least 5 types of tests. The first type is request validation tests: missing subtitle, empty string, all whitespace, and exceeding 100 characters should all get a clear 400 response. The second type is permission tests: unauthenticated access returns 401, regular user access returns 403, and admin access succeeds. The third type is business tests: when a product doesn't exist, PRODUCT_NOT_FOUND is returned; when it exists, the update succeeds and the database field is modified. The fourth type is cache tests: first create a product detail cache, then update the subtitle, and confirm the cache is deleted or the next detail request returns the new value. The fifth type is trim rule tests: when the input has leading and trailing spaces, the final saved value is the one with outer spaces removed.
These tests are not for pursuing coverage numbers, but for solidifying the key contracts of "a field passing through the system." When frontend developers change components, they write snapshot or interaction tests; when backend developers change APIs, they should also write contract tests and business tests. Especially points like permissions, caching, and transactions, which are not easily covered directly by frontend pages, should be protected by backend tests.
You can name tests like business specifications, for example, updateSubtitleShouldRequireAdminRole, updateSubtitleShouldEvictProductDetailCache, updateSubtitleShouldRejectBlankSubtitle. Good test names allow someone later to understand why the requirement was designed this way without reading the full implementation. When you can write these tests for a small field, it shows you've started to shift from a "frontend developer who can call APIs" to a "full-stack engineer who can maintain system rules."
Appendix J: Why Even Small Requirements Need Acceptance Criteria
If a requirement like "modify product subtitle" has no acceptance criteria, it's easy for everyone to have a different understanding. The product manager might think an empty subtitle is not allowed, the frontend might think an empty string can clear it, and the backend might reject it based on @NotBlank; the product manager might think it takes effect immediately on the frontend after modification, but if the backend forgets to delete Redis, it can only wait for TTL; the tester might only verify the admin success, not whether a regular user is rejected. Acceptance criteria are about writing out these implicit disagreements in advance.
The acceptance criteria for this requirement can be written as: An admin carrying a valid JWT calls PATCH /api/admin/products/{id}/subtitle, and the update succeeds when passing 1 to 100 characters; unauthenticated returns 401; non-admin returns 403; an empty string or exceeding the length returns 400; a non-existent product returns a business 404; after a successful update, the public detail API returns the new subtitle; the response body continues to use the unified ApiResponse, including code, message, data, traceId, and timestamp. Once these criteria are clear, the frontend, backend, and testing can work around the same contract.
Frontend developers doing full-stack work should especially value acceptance criteria, because they help you upgrade from "I finished writing the code" to "I delivered the requirement." Code is just the implementation; acceptance criteria indicate whether the implementation meets the business need.
Appendix K: The Last 10-Minute Check Before a Requirement Goes Live
Before submitting this requirement, you can do a quick check in 10 minutes. First, see if the API path conforms to REST semantics: modifying a subtitle uses PATCH, not a vague new POST /updateSubtitle. Next, see if the request body only contains the fields needed for this action, avoiding the frontend passing an entire product object leading to accidental updates. Then, check if the validation annotations cover empty values and length, and whether business exceptions can be converted into a unified response by GlobalExceptionHandler. Then, check permissions: is the admin path under /api/admin/**, and can only ADMIN access it? Finally, check the cache: is the public detail cache deleted after a successful update?
In these 10 minutes, also open the tests or curl scripts and run at least the 5 paths: success, validation failure, unauthenticated, non-admin, and product not found. Many online problems are not caused by complex algorithms, but by a common exception path that no one verified. The simpler a small requirement is, the more likely it is to be underestimated; the more it is underestimated, the more it needs a checklist to prevent omissions.
Finally, connect this requirement with the frontend release: the frontend button can be hidden, the form can be disabled, but the real permissions, validation, and data consistency must be guaranteed by the backend. Only when both the frontend and backend implement according to the same contract will the user's operation be stable.