跪拜 Guibai
← Back to the summary

Tracing a Product Detail API Call Through Every Layer of a Spring Boot App

This article is aimed at frontend developers who have completed the first 7 foundational chapters. We will use the real source code of the current fullstack-mall backend project to completely dissect the public product detail endpoint GET /api/products/{id}: how the request enters the Controller, why this endpoint allows anonymous access, how the Facade first checks the Redis cache, how cache hits, misses, null hits, and Redis failures are handled respectively, how MyBatis-Plus queries MySQL, how the product and category tables are assembled into a response DTO, and how business exceptions are transformed into a unified JSON format. The Vue / Axios / TypeScript code in the text is only "frontend-side illustrative code" for analogical understanding; the current repository does not contain real frontend source code.

GitHub Repository: https://github.com/2530622506/fullstack-mall

01|(Frontend to Fullstack) What Should a Frontend Developer Look at First When Opening a Spring Boot Project?

02|(Frontend to Fullstack) From pnpm dev to Spring Boot Startup: How Does the Backend Service Actually Run?

03|(Frontend to Fullstack) After an Axios Request Enters the Backend: How Do Controller, Request, and Response Receive It?

04|(Frontend to Fullstack) Why Is Frontend State Not Enough? From Page Data to MySQL Persistence

05|(Frontend to Fullstack) How Does the Backend Operate the Database Without Writing a Bunch of SQL? An Introduction to MyBatis-Plus

06|(Frontend to Fullstack) What Does the Login Backend Actually Do? JWT, Spring Security, and the Permission Chain

07|(Frontend to Fullstack) Why Does the Backend Also Need Caching? Understanding Redis from a Frontend Caching Mindset

08|(Frontend to Fullstack) The Complete Chain Behind a Product Detail Endpoint: HTTP, Redis, MySQL, and JSON

09|(Frontend to Fullstack) Why Can't Products Be Listed and Delisted Arbitrarily? An Introduction to Backend State Machine Thinking

1. What Problem Does This Solve

The previous 7 chapters covered the project map, Spring Boot startup, Controller / Request / Response, MySQL, MyBatis-Plus, JWT / Spring Security, and Redis caching. By now, you've learned many independent concepts, but the most important skill in real backend development isn't memorizing concepts—it's being able to trace a request from entry to exit, understanding why each layer exists, what it processes, and how it returns when something fails.

This chapter selects the public product detail endpoint as the first complete chain:

GET /api/products/{id}

This endpoint looks very simple; the frontend product detail page only needs the product title, subtitle, description, category name, status, creation time, and update time. But the real backend chain is not as simple as "query the mall_product table and return." To ensure public visibility, performance, and unified error handling, the current project goes through at least these steps:

  1. The request enters Spring Boot and first passes through TraceIdFilter to generate or propagate an X-Trace-Id;
  2. The request passes through Spring Security; because /api/products/** is on the whitelist, anonymous users can also access it;
  3. ProductController.detail binds the path parameter via @PathVariable Long id;
  4. The Controller calls IProductFacade.getPublishedProduct(id);
  5. ProductFacade first calls ProductDetailCacheService.lookup(id) to query Redis;
  6. If the cache is a HIT, it directly returns ProductDetailResponse;
  7. If the cache is a NULL_HIT, it restores the business 404;
  8. If the cache is a MISS, DEGRADED, or BYPASS, it falls back to MySQL;
  9. ProductDbService.requirePublishedById forces the product status to be ON_SALE;
  10. CategoryDbService.requireEnabledCategory forces the category to be enabled;
  11. The Facade assembles ProductEntity and CategoryEntity into ProductDetailResponse;
  12. After a successful query, the response JSON is backfilled into Redis;
  13. If the product does not exist, is not on sale, the category does not exist, or is disabled, a BusinessException is thrown and a short TTL null value cache is written;
  14. The Controller wraps the successful response with ApiResponse.success;
  15. GlobalExceptionHandler wraps business exceptions into a unified error JSON.

This chain is very suitable for frontend-to-backend learning because it contains the 6 most common types of problems in backend endpoint development: HTTP entry, permission boundaries, caching strategies, database queries, DTO assembly, and exception responses. After studying this chapter, you should be able to: given an endpoint path, know where to find the Controller; seeing a Controller, know which Facade it calls; seeing a Facade, know how caching and database are orchestrated; seeing a DbService, know the corresponding SQL conditions; seeing a Response DTO, know what fields the frontend actually receives; and seeing a 404 / 500 / stale data issue, know which layers to start investigating.

2. Analogy with Frontend Knowledge: An API Request for a Product Detail Page

A frontend product detail page typically sends a request based on route parameters:

// Frontend-side illustrative code: Product detail page loading, does not mean this file exists in the repository
async function loadProductDetail(route: RouteLocationNormalizedLoaded) {
  const id = Number(route.params.id)
  const response = await http.get<ApiResponse<ProductDetail>>(`/api/products/${id}`)
  productDetail.value = response.data.data
}

From a frontend perspective, this is just a GET request. The questions you usually care about are: whether the route parameter is a number, whether the endpoint returns 200, whether the response fields can be rendered, how to display "Product Not Found" on a 404, and how to handle loading and error states.

But the backend perspective breaks this single request down more finely:

What the Frontend Sees What the Backend Actually Cares About Current Project Code
Entering the product detail page Whether the request path matches the Controller @RequestMapping("/api/products") + @GetMapping("/{id}")
Getting id from the route Whether the path variable can be converted to Long @PathVariable Long id
Sending a GET request Whether the URL allows anonymous access /api/products/**.permitAll() in SecurityConfig
Waiting for the API response Whether it hits Redis ProductDetailCacheService.lookup
Rendering the product title Whether the Response DTO contains the field ProductDetailResponse.title
Displaying the category name The product table has no category name; it needs to query the category table for assembly CategoryDbService.requireEnabledCategory
Displaying a 404 page How business exceptions are converted to a unified JSON BusinessException + GlobalExceptionHandler
Faster access on a second visit The second time might go through a Redis HIT ProductDetailCacheStatus.HIT

Frontend developers are also accustomed to doing caching at the page level. For example, when entering the same product repeatedly, it's read from Pinia or a Map first:

// Frontend-side illustrative code: Page-level caching, for analogy only
const productDetailCache = new Map<number, ProductDetail>()

async function getProductDetail(id: number) {
  const cached = productDetailCache.get(id)
  if (cached) {
    return cached
  }
  const response = await http.get(`/api/products/${id}`)
  productDetailCache.set(id, response.data.data)
  return response.data.data
}

The Redis Cache Aside pattern implemented in the current project backend is somewhat similar to this idea: check the cache first, query the real source if not found, and backfill the cache after finding it. But the scope of impact differs. Frontend caching only affects the current browser; Redis caching affects all users. If the frontend cache is wrong, a refresh usually fixes it; if the backend Redis cache is wrong, all users might see stale data. Therefore, the backend chain must additionally handle TTL, cache deletion, null value caching, Redis failure degradation, and bad cache deletion.

3. Explanation of Core Backend Concepts

3.1 An Endpoint Is Not a Method, It's a Chain

When first learning backend, it's easy to understand an endpoint as just a method in a Controller. For example, seeing:

@GetMapping("/{id}")
public ApiResponse<ProductDetailResponse> detail(@PathVariable Long id, HttpServletRequest servletRequest) {
    ProductDetailResponse response = productFacade.getPublishedProduct(id);
    return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}

You might think "this endpoint is just these few lines." But in real execution, it has Filters, Security, parameter binding before it; and Facade, caching, DbService, MyBatis-Plus, MySQL, exception handling, and JSON serialization after it. The Controller is just the HTTP entry point and should not carry all the business logic.

You can think of a backend endpoint like a frontend page rendering chain: the route enters the page component, the page calls a composable, the composable calls the API client, the API client goes through an Axios interceptor, the response comes back and enters the store, then triggers a view update. The backend also has layered responsibilities, just with different duties at each layer.

3.2 Public Endpoints Also Need Security Rules

Product details are an anonymous endpoint, but "anonymous" does not mean "no security design." The current project explicitly writes in SecurityConfig:

.requestMatchers(
    "/api/products",
    "/api/products/**"
).permitAll()

This indicates that the public product list and details allow anonymous access. Why write rules? Because the project also has /api/admin/** management endpoints and other /api/** login endpoints. Without an explicit whitelist, public product details might be blocked by /api/**.authenticated(), preventing unauthenticated users from viewing products.

Frontend-to-backend developers should build a habit: for every new API added, ask what its access boundary is. Public product details can be anonymous; shopping cart details must require login; admin product editing must require ADMIN; payment callbacks might not be user logins but shared secrets or signature verification. Don't just check if a URL works; check who should be able to access it.

3.3 Public Product Details Only Allow ON_SALE

The product table has 3 statuses:

public enum ProductStatus {
    DRAFT,
    ON_SALE,
    OFF_SHELF
}

Public product details cannot expose draft and delisted products to anonymous users. The current project doesn't let the frontend pass status=ON_SALE; instead, it forces the query in the backend ProductDbService.requirePublishedById:

.eq(ProductEntity::getId, productId)
.eq(ProductEntity::getStatusCode, ProductStatus.ON_SALE.name())

This is very important. Frontend request parameters are untrustworthy; the visibility of public endpoints must be enforced by the server. Even if someone directly accesses /api/products/2, and product 2 is DRAFT, the backend will return a 404 as PRODUCT_NOT_FOUND.

3.4 Product Details Require Assembly from Both Product and Category Tables

The mall_product table has category_id but no categoryName. This is a common practice in database design: the product table stores the category ID, and the category name is in the mall_category table. The API response needs to display the category name, so the Facade must first query the product, then query the category based on the product's categoryId, and the category must be enabled.

ProductDetailResponse contains:

private Long categoryId;
private String categoryName;
private String title;
private String subtitle;
private String description;
private ProductStatus status;

Among these, categoryName is assembled, not a redundant field from the product table. The frontend sees a flat JSON, but the backend might have queried multiple tables and performed business validations.

3.5 Redis Is a Performance Layer, Not the Source of Truth

The product detail endpoint queries Redis first to accelerate hot reads. But Redis cannot be the source of truth. The current project clearly states in the comments of ProductFacade.getPublishedProduct: MISS, DEGRADED, and BYPASS all fall back to MySQL; Redis is not the source of truth for products.

This statement must be remembered. When a cache hit occurs, it can be returned directly because the cache is a copy built from the real data in MySQL; when there's no cache, you cannot say the product doesn't exist—you must query MySQL; when Redis fails, you cannot make the product detail completely unavailable, but should try to query MySQL.

3.6 Business Exceptions and HTTP Status Codes Are Not the Same, But They Must Correspond

The current project uses BusinessException carrying an ApiCode and HTTP status. For example, when a product doesn't exist:

new BusinessException(
    ApiCode.PRODUCT_NOT_FOUND,
    ApiCode.PRODUCT_NOT_FOUND.defaultMessage(),
    HttpStatus.NOT_FOUND
)

After GlobalExceptionHandler catches the BusinessException, it returns a unified ApiResponse.error(...), with the HTTP status code being the httpStatus from the exception. So the frontend can look at both the HTTP 404 and the response body code=PRODUCT_NOT_FOUND. Business logic is recommended to primarily rely on the stable code, and display copy can use the message.

3.7 traceId Is a Joint Frontend-Backend Investigation Identifier

TraceIdFilter generates or propagates an X-Trace-Id for each request and writes it into the request attribute and response header. When the Controller returns successfully, it puts the traceId into ApiResponse via TraceIdContext.get(servletRequest).

When the frontend encounters an online issue, if it can send the traceId to the backend, the backend can more easily locate the corresponding request in the logs. You can think of the traceId as a tracking number for a single request: from entry, caching, database, to exception handling, everything can be investigated around it.

4. Corresponding Files in This Project

This chapter mainly involves these real files:

Layer File Role
Security Config backend/service/src/main/java/com/example/fullstackmall/service/config/SecurityConfig.java Allows anonymous access to /api/products and /api/products/**
Trace backend/service/src/main/java/com/example/fullstackmall/service/common/trace/TraceIdFilter.java Creates or propagates X-Trace-Id for each request
Trace backend/service/src/main/java/com/example/fullstackmall/service/common/trace/TraceIdContext.java Controller and exception handler read the current traceId
HTTP Entry backend/service/src/main/java/com/example/fullstackmall/service/product/ProductController.java Defines public product list and detail endpoints
Contract Interface backend/contract/src/main/java/com/example/fullstackmall/contract/product/IProductFacade.java Defines the use case methods exposed by the product module
Business Orchestration backend/service/src/main/java/com/example/fullstackmall/service/product/ProductFacade.java Orchestrates caching, product query, category query, DTO assembly, exceptions, and backfill
Product DB backend/service/src/main/java/com/example/fullstackmall/service/product/service/ProductDbService.java MyBatis-Plus queries the product table; public details force ON_SALE
Category DB backend/service/src/main/java/com/example/fullstackmall/service/category/service/CategoryDbService.java Queries categories and requires them to be enabled
Product Entity backend/service/src/main/java/com/example/fullstackmall/service/product/entity/ProductEntity.java Maps the mall_product table
Product Mapper backend/service/src/main/java/com/example/fullstackmall/service/product/mapper/ProductMapper.java Extends MyBatis-Plus BaseMapper
Cache Service backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheService.java Product detail cache key, JSON, TTL, null values, degradation, deletion
Cache Result backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheLookup.java Expresses cache query status and result
Response DTO backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductDetailResponse.java Defines the product detail fields the frontend ultimately receives
Status Enum backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductStatus.java Defines DRAFT, ON_SALE, OFF_SHELF
Unified Response backend/contract/src/main/java/com/example/fullstackmall/contract/common/ApiResponse.java Wraps success and failure responses
Exception Handling backend/service/src/main/java/com/example/fullstackmall/service/common/exception/GlobalExceptionHandler.java Converts BusinessException and other exceptions into a unified JSON
Database Structure sql/01_schema.sql Defines mall_category, mall_product tables and indexes
Seed Data sql/02_seed.sql Provides ON_SALE, DRAFT, OFF_SHELF demo products
Tests ProductControllerTest.java, ProductFacadeCacheTest.java, ProductMapperTest.java Verifies public visibility, cache branches, and database mapping

This table itself is your roadmap for reading backend endpoints in the future: first find the Controller, then the Facade, then the DbService and cache service, then the DTO, Entity, SQL, and tests. Don't start by globally searching for all fields with the same name; that's an easy way to get lost.

5. Mermaid Diagrams: Drawing the Complete Chain

5.1 Full Chain for a Successful Request

sequenceDiagram
    autonumber
    participant Browser as Frontend Browser
    participant Trace as TraceIdFilter
    participant Security as Spring Security
    participant Controller as ProductController
    participant Facade as ProductFacade
    participant Cache as ProductDetailCacheService
    participant Redis as Redis
    participant ProductDb as ProductDbService
    participant CategoryDb as CategoryDbService
    participant MySQL as MySQL

    Browser->>Trace: GET /api/products/1
    Trace->>Security: Write X-Trace-Id
    Security->>Controller: /api/products/** permitAll
    Controller->>Facade: getPublishedProduct(1)
    Facade->>Cache: lookup(1)
    Cache->>Redis: GET mall:product:detail:v1:1
    alt HIT
        Redis-->>Cache: ProductDetailResponse JSON
        Cache-->>Facade: HIT(response)
        Facade-->>Controller: response
    else MISS / DEGRADED / BYPASS
        Cache-->>Facade: MISS/DEGRADED/BYPASS
        Facade->>ProductDb: requirePublishedById(1)
        ProductDb->>MySQL: select where id=1 and status_code='ON_SALE'
        MySQL-->>ProductDb: ProductEntity
        Facade->>CategoryDb: requireEnabledCategory(categoryId)
        CategoryDb->>MySQL: select category where id=? and status=1
        MySQL-->>CategoryDb: CategoryEntity
        Facade->>Cache: put(1, response)
        Cache->>Redis: SET key JSON TTL
        Facade-->>Controller: response
    end
    Controller-->>Browser: ApiResponse

5.2 Cache Branch Decision Diagram

flowchart TD
    A[Enter ProductFacade.getPublishedProduct] --> B[lookup productId]
    B --> C{ProductDetailCacheStatus}
    C -->|HIT| D[Directly return cached response]
    C -->|NULL_HIT| E[Throw 404 based on nullApiCode]
    C -->|MISS| F[Fallback to MySQL]
    C -->|DEGRADED| F
    C -->|BYPASS| F
    F --> G[requirePublishedById: id + ON_SALE]
    G --> H[requireEnabledCategory: status=1]
    H --> I[toDetailResponse]
    I --> J[put normal detail cache]
    J --> K[Return success]
    G -->|PRODUCT_NOT_FOUND| L[putNull short TTL]
    H -->|CATEGORY_NOT_FOUND| L
    L --> M[GlobalExceptionHandler returns 404 JSON]

5.3 Permission Difference Between Public and Admin Details

flowchart LR
    A["Anonymous User"] --> B["GET /api/products/1"]
    B --> C["/api/products/** permitAll"]
    C --> D[Can only see ON_SALE products]

    E["Regular User"] --> F["GET /api/admin/products"]
    F --> G["/api/admin/** hasRole ADMIN"]
    G --> H[403 Forbidden]

    I["Admin"] --> J["GET /api/admin/products"]
    J --> G
    G --> K[Can query DRAFT / ON_SALE / OFF_SHELF]

Public details are not "everyone can see all products," but rather "anonymous users can also see listed products." Only the backend endpoints allow admins to view operational statuses like drafts and delisted items.

5.4 Product Detail Data Assembly Diagram

flowchart TB
    A[mall_product] -->|ProductEntity| C[ProductFacade.toDetailResponse]
    B[mall_category] -->|CategoryEntity| C
    C --> D[ProductDetailResponse]
    D --> E[ApiResponse.success]
    E --> F[Frontend Product Detail Page]

    A -.Fields.-> A1[id/category_id/title/subtitle/description/status_code/created_by/created_at/updated_at]
    B -.Fields.-> B1[id/name/status/sort_order]
    D -.Fields.-> D1[id/categoryId/categoryName/title/subtitle/description/status/createdBy/createdAt/updatedAt]

The categoryName the frontend receives comes from the category table, and status is converted from the status_code in the product table to the ProductStatus enum. The response DTO is not a simple copy of the database tables.

6. Reading the Source Code Segment by Segment

6.1 SecurityConfig: Why Anonymous Users Can Access Product Details

In Chapter 6, we discussed that Spring Security sits before the Controller. Although the product detail endpoint doesn't require login, it still passes through Security. The current project configures in the whitelist:

.requestMatchers(
    "/api/products",
    "/api/products/**"
).permitAll()

So GET /api/products/1 can enter the Controller without a token. Two concepts need to be distinguished here:

  1. Whether accessing the endpoint requires login;
  2. What business data the endpoint internally returns.

Public product details don't require login, but the business layer still only returns ON_SALE products. If a product is a draft or delisted, even if the URL allows anonymous access, the DbService will treat it as non-existent. This is the coordination between "security access rules" and "business visibility rules."

6.2 TraceIdFilter: The Request Gets a traceId as Soon as It Enters

TraceIdFilter uses @Order(Ordered.HIGHEST_PRECEDENCE), indicating it executes very early. It reads the request header X-Trace-Id; if valid, it propagates it; if invalid or absent, it generates a UUID string with dashes removed.

request.setAttribute(TRACE_ID_ATTRIBUTE, traceId);
response.setHeader(TRACE_ID_HEADER, traceId);

These two lines make the same traceId appear in both the request attribute and the response header. Subsequent Controllers can write it into ApiResponse via TraceIdContext.get(request); the exception handler can also use the same traceId to return an error response.

The frontend side can pass it like this:

// Frontend-side illustrative code: Actively pass traceId for easier frontend-backend debugging
http.get('/api/products/1', {
  headers: {
    'X-Trace-Id': 'product001trace'
  }
})

The current project requires the traceId to contain only letters, numbers, underscores, and hyphens, with a length of 8-64. Illegal values will be replaced by the backend to prevent external arbitrary strings from polluting the logs.

6.3 ProductController: The HTTP Entry Point for Public Products

ProductController has:

@RestController
@RequestMapping("/api/products")
@Tag(name = "Public Products")
public class ProductController

This indicates it is responsible for public product endpoints. The detail method is:

@GetMapping("/{id}")
@Operation(summary = "Query listed product detail")
public ApiResponse<ProductDetailResponse> detail(
        @PathVariable Long id,
        HttpServletRequest servletRequest
) {
    ProductDetailResponse response = productFacade.getPublishedProduct(id);
    return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}

A few points here:

If the frontend passes /api/products/abc, the path variable cannot be converted to Long, and the request might fail during the parameter binding phase; if it passes /api/products/999999, the parameter can be bound successfully, but the business layer cannot find the product and will return a business 404. These two error locations are different and should be distinguished during troubleshooting.

6.4 IProductFacade: The Controller Depends on a Use Case Contract

IProductFacade defines the use cases provided by the product module to the outside, including creating products, changing statuses, querying the admin list, querying the public list, querying public details, modifying subtitles, etc. The comment for the public detail method states:

/**
 * Query public product detail; treat as non-existent if the product is not listed or the category is disabled.
 */
ProductDetailResponse getPublishedProduct(Long productId);

This comment is very crucial: it writes the business semantics onto the contract. Public details are not "query any product by ID," but "query public product details." If a product is not listed or its category is disabled, it is treated as non-existent for anonymous users. This kind of contract comment helps align frontend and backend expectations: the frontend should not expect to see drafts in public details just because the admin panel can see them.

6.5 ProductFacade.getPublishedProduct: The Brain of the Chain

The core code is as follows:

ProductDetailCacheLookup cacheLookup = productDetailCacheService.lookup(productId);
if (cacheLookup.getStatus() == ProductDetailCacheStatus.HIT) {
    return cacheLookup.getResponse();
}
if (cacheLookup.getStatus() == ProductDetailCacheStatus.NULL_HIT) {
    ApiCode apiCode = cacheLookup.getNullApiCode();
    throw new BusinessException(apiCode, apiCode.defaultMessage(), HttpStatus.NOT_FOUND);
}

The first step is to check the cache. If a normal detail is hit, return it directly; if a null value is hit, restore the original 404 business exception. This means Redis can store not only successful data but also a short TTL conclusion that "this ID currently does not exist or is not visible."

Next:

// MISS, DEGRADED, and BYPASS all fallback to MySQL; Redis is not the source of truth for products.
try {
    ProductEntity product = productDbService.requirePublishedById(productId);
    CategoryEntity category = categoryDbService.requireEnabledCategory(product.getCategoryId());
    ProductDetailResponse response = toDetailResponse(product, category);
    productDetailCacheService.put(productId, response);
    return response;
} catch (BusinessException exception) {
    if (exception.getCode() == ApiCode.PRODUCT_NOT_FOUND
            || exception.getCode() == ApiCode.CATEGORY_NOT_FOUND) {
        productDetailCacheService.putNull(productId, exception.getCode());
    }
    throw exception;
}

This is a complete Cache Aside pattern: MISS means Redis doesn't have it; DEGRADED means Redis is abnormal; BYPASS means the cache is disabled by configuration. None of these three should directly fail; instead, they should query MySQL. After finding it, backfill the cache; if a stable 404 is not found, write a null value cache, and then continue to throw the original business exception.

Note that the Facade does not swallow the exception in the catch block, nor does it cache all exceptions as null values. It only caches PRODUCT_NOT_FOUND and CATEGORY_NOT_FOUND. Other business conflicts or system errors cannot be disguised as "product not found."

6.6 ProductDetailCacheService.lookup: How Redis Branches Are Generated

The key prefix for ProductDetailCacheService is:

private static final String KEY_PREFIX = "mall:product:detail:v1:";

The key for product ID 1 is:

mall:product:detail:v1:1

lookup first checks if caching is enabled. If not, it returns BYPASS. When enabled, it reads Redis via RedisOperatorClient.get(key). A read exception returns DEGRADED. A read result of null returns MISS. If the value starts with __NULL__:, it parses the business code; if valid, it returns NULL_HIT; if invalid, it deletes the bad cache and returns MISS. If it's normal JSON, it deserializes it into ProductDetailResponse; success returns HIT, failure deletes the bad cache and returns MISS.

This code reflects the engineering quality of backend caching:

  1. Clear statuses, so the caller doesn't have to guess;
  2. Redis failures can be degraded;
  3. Bad caches are cleaned up;
  4. Null value caches are distinguished from normal caches;
  5. The cache switch is configurable;
  6. Events like HIT, MISS, PUT, DEGRADED are logged.

6.7 ProductDbService.requirePublishedById: Public Visibility Enforced by the Backend

The database access method is:

public ProductEntity requirePublishedById(Long productId) {
    ProductEntity product = lambdaQuery()
            .eq(ProductEntity::getId, productId)
            .eq(ProductEntity::getStatusCode, ProductStatus.ON_SALE.name())
            .one();
    if (product == null) {
        throw productNotFound();
    }
    return product;
}

This roughly corresponds to the SQL:

SELECT *
FROM mall_product
WHERE id = ?
  AND status_code = 'ON_SALE'
LIMIT 1;

So in the seed data, product 2 Nintendo Switch OLED is DRAFT, and product 3 Java Core Technology Volume I is OFF_SHELF. Anonymous access to /api/products/2 or /api/products/3 should both return PRODUCT_NOT_FOUND. This is not because the database lacks these rows, but because public details do not permit viewing them.

6.8 CategoryDbService.requireEnabledCategory: Disabled Categories Are Also Treated as Non-Existent

A product belongs to a category. Even if the product itself is ON_SALE, if the category is disabled, the public detail should not be displayed. The category query logic is:

public CategoryEntity requireEnabledCategory(Long categoryId) {
    CategoryEntity category = requireCategory(categoryId);
    if (category.getStatus() != ENABLED) {
        throw categoryNotFound();
    }
    return category;
}

ENABLED is 1. If the category does not exist or its status is not 1, a CATEGORY_NOT_FOUND is thrown with HTTP status 404. For the frontend, this handling is very friendly: the public detail page doesn't need to know the internal difference between "product not found" and "category disabled"; it can uniformly display "product unavailable" or "product not found." But the response code still retains the more specific business code for easier debugging.

6.9 toDetailResponse: Assembling the Response DTO from Entities

The Facade ultimately calls toDetailResponse(product, category). It assembles fields from the product and category tables into:

new ProductDetailResponse(
    product.getId(),
    product.getCategoryId(),
    category.getName(),
    product.getTitle(),
    product.getSubtitle(),
    product.getDescription(),
    ProductStatus.valueOf(product.getStatusCode()),
    product.getCreatedBy(),
    product.getCreatedAt(),
    product.getUpdatedAt()
)

Three conversions to note here:

  1. category.getName() becomes categoryName in the response;
  2. The status_code string is converted to an enum via ProductStatus.valueOf;
  3. created_at and updated_at remain as LocalDateTime, serialized by Spring Boot's JSON configuration.

The ProductDetailResponse the frontend receives is a DTO designed by the backend for the page, not a database Entity. A DTO can contain composite fields and can also hide internal fields. Do not forcibly equate "database table structure" with "API response structure."

6.10 ApiResponse.success: Unified Success Response

The Controller finally returns:

ApiResponse.success(response, TraceIdContext.get(servletRequest))

The success response structure comes from ApiResponse:

private String code;
private String message;
private T data;
private String traceId;
private Instant timestamp;

So the frontend does not directly receive a product object, but a unified envelope:

{
  "code": "SUCCESS",
  "message": "Operation successful",
  "data": {
    "id": 1,
    "categoryId": 1,
    "categoryName": "Mobile Digital",
    "title": "Like-new iPhone 15"
  },
  "traceId": "...",
  "timestamp": "..."
}

When encapsulating the API client on the frontend, it should be clear whether to return the entire ApiResponse or to extract data.data in an interceptor. Either way, the code and traceId must be preserved for error cases to facilitate troubleshooting.

6.11 GlobalExceptionHandler: How Business Failures Become JSON

If a product does not exist, ProductDbService throws BusinessException(PRODUCT_NOT_FOUND, 404). This exception is not manually caught in the Controller but is uniformly handled by GlobalExceptionHandler:

@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(...) {
    ApiResponse<Void> response = ApiResponse.error(
            exception.getCode(),
            exception.getMessage(),
            null,
            TraceIdContext.get(request)
    );
    return ResponseEntity.status(exception.getHttpStatus()).body(response);
}

This is why the business layer only needs to throw exceptions, and the frontend can still receive a unified JSON. It also explains why a backend project should not use return null or return false everywhere to indicate failure: exceptions and a unified handler make the failure path clearer.

7. Local Run / curl / Redis Verification

The following assumes you have started MySQL, Redis, and the backend service:

docker compose -f docker-compose.dev.yml up -d mysql redis
mvn -pl service -am spring-boot:run

7.1 Requesting a Listed Product Detail

In the seed data, product 1 is ON_SALE:

curl -i 'http://localhost:8080/api/products/1'

Expected HTTP 200, response body code=SUCCESS, data.title similar to Like-new iPhone 15, data.categoryName similar to Mobile Digital. Because /api/products/** allows anonymous access, this request does not need a token.

7.2 Observing a Cache Hit on the Second Request

Make two consecutive requests:

curl -sS 'http://localhost:8080/api/products/1' > /tmp/product-1-first.json
curl -sS 'http://localhost:8080/api/products/1' > /tmp/product-1-second.json

If Redis had no key for the first request, the logs should show MISS and PUT; the second should show HIT. You can also check with Redis:

docker exec -it fullstack-mall-redis redis-cli get mall:product:detail:v1:1

If a JSON string is returned, it means the product detail cache has been written.

7.3 Checking the TTL

docker exec -it fullstack-mall-redis redis-cli ttl mall:product:detail:v1:1

The default TTL for a normal product detail is 10 minutes, so you will see a number of seconds less than or equal to 600. This value decreases every second. After the TTL expires, the next request will MISS again and fall back to MySQL.

7.4 Requesting a Draft Product Should Return 404

In the seed data, product 2 is DRAFT:

curl -i 'http://localhost:8080/api/products/2'

Expected return is 404, response body code=PRODUCT_NOT_FOUND. Note: product 2 does exist in the database, but the public detail forces ON_SALE, so it is treated as non-existent.

7.5 Requesting a Delisted Product Should Return 404

In the seed data, product 3 is OFF_SHELF:

curl -i 'http://localhost:8080/api/products/3'

Expected is also a 404. This proves that the public detail endpoint does not query unconditionally by ID, but has visibility rules.

7.6 Requesting a Non-Existent Product and Observing the Null Value Cache

curl -i 'http://localhost:8080/api/products/999999'

The first time will query MySQL and write a short TTL null value. Check Redis:

docker exec -it fullstack-mall-redis redis-cli get mall:product:detail:v1:999999

You might see:

__NULL__:PRODUCT_NOT_FOUND

Request /api/products/999999 again; it should hit NULL_HIT and not query MySQL again.

7.7 Manually Deleting the Cache and Requesting Again

docker exec -it fullstack-mall-redis redis-cli del mall:product:detail:v1:1
curl -i 'http://localhost:8080/api/products/1'

After deletion, the next request will rebuild the cache. This is the same idea as calling productDetailCacheService.evict(productId) after a successful admin write operation.

7.8 Requesting with a traceId and Observing the Response Header and Body

curl -i 'http://localhost:8080/api/products/1' \
  -H 'X-Trace-Id: product001trace'

You should see the same value in the response header X-Trace-Id and the response body traceId. During frontend-backend joint debugging, this value helps locate logs.

7.9 Verifying Degradation After Stopping Redis

docker stop fullstack-mall-redis
curl -i 'http://localhost:8080/api/products/1'
docker start fullstack-mall-redis

If MySQL is normal, the product detail should still be returned as much as possible, only the logs will show DEGRADED. This proves that Redis is a performance layer for this endpoint, not a hard dependency.

8. Common Mistakes

8.1 Thinking /api/products/{id} Will Return Products of Any Status

Public details only return ON_SALE. Draft and delisted products are treated as non-existent for anonymous users. If you see a product exists in the database but the endpoint returns 404, first check if status_code is ON_SALE.

8.2 Ignoring Category Status

A product being ON_SALE does not guarantee it can be publicly displayed. If the category does not exist or is disabled, CategoryDbService.requireEnabledCategory will throw CATEGORY_NOT_FOUND. When troubleshooting a 404 for public details, check both the product and the category.

8.3 Treating a Redis MISS as the Product Not Existing

MISS only means Redis has no cache, not that MySQL has no data. The current project falls back to MySQL after a MISS. Only when the database also finds nothing, or the cache hits a legitimate null value marker, is a business 404 returned.

8.4 Directly Returning a 500 When Redis Errors

The product detail cache is a performance layer; when a Redis read fails, it should degrade to querying MySQL. The current project returns DEGRADED and continues to fall back. If you change the code later to directly throw the Redis exception, the stability of the product detail endpoint will worsen.

8.5 Forgetting to Delete the Cache After Modifying a Product

If the admin panel modifies the title, subtitle, description, category, or status but does not delete mall:product:detail:v1:{id}, users may continue to see old details. In a later Chapter 15, when doing "Admin Modifies Product Subtitle," the post-write cache invalidation will be specifically connected.

8.6 Piling Too Much Logic in the Controller

The Controller should accept parameters, call the Facade, and wrap the response. Do not write Redis queries, MySQL queries, category validation, and DTO assembly all in the Controller. Otherwise, the endpoint quickly becomes hard to test, hard to reuse, and hard to troubleshoot.

8.7 Frontend Only Looking at HTTP Status, Not the Business Code

HTTP 404 can tell you the resource is inaccessible; code=PRODUCT_NOT_FOUND or CATEGORY_NOT_FOUND can tell you the more specific reason. Frontend logic should primarily rely on the stable business code, not parse the message text.

8.8 Forgetting to Preserve the traceId

During online troubleshooting, a user just saying "the product detail won't open" is usually not enough. The frontend should preserve the traceId in error logs or feedback so the backend can quickly locate the corresponding request log.

8.9 Returning the Entity Directly to the Frontend

The current project returns ProductDetailResponse, not ProductEntity directly. A DTO can hide internal fields, compose category names, and convert enums; it is part of the API contract. Returning an Entity directly exposes the database structure to the frontend and makes future table changes more difficult.

8.10 Using Frontend Parameters to Determine Public Status

In the public product list, even if the frontend passes status=OFF_SHELF, the backend will force querying only ON_SALE. The same applies to public details; the status is determined by the backend query conditions. Do not build business visibility on the assumption that the frontend will conscientiously pass parameters.

8.11 Troubleshooting Checklist: What Order to Check When Product Details Won't Load

When frontend developers first start troubleshooting backend issues, the most common mistake is to stare directly at a specific piece of code. For example, seeing a "Product Not Found" prompt on the page and immediately going to the database to check if the mall_product table has that record; seeing an old title displayed and immediately suspecting the endpoint wasn't redeployed; seeing occasional failures and immediately suspecting Redis is unstable. In a real project, a more efficient way is to troubleshoot from the outside in along the request chain, answering only one small question at each layer.

The first step is to confirm whether the request reached the correct endpoint. In the browser's Network panel or with curl, check clearly that the URL is /api/products/1, not /api/admin/products/1, nor /products/1 missing a gateway prefix. Public details go through ProductController.detail; admin details go through the admin Controller. If the path is wrong, all subsequent database and cache analysis is meaningless.

The second step is to check permissions. In the current project, /api/products and /api/products/** are on the anonymous whitelist, so public products can be accessed without login. If you get a 401 or 403, first check if the request path entered /api/admin/**, or if the security configuration was changed. This thinking is very similar to frontend route guards: first determine if the page was mistakenly placed in a route group that "requires login."

The third step is to look at the response body's code and traceId. If the HTTP status is 404 but the business code is PRODUCT_NOT_FOUND, it means the backend considers the "publicly visible product does not exist"; if it's CATEGORY_NOT_FOUND, it means the product itself might have been found, but the associated category does not exist or is disabled. The traceId is used to correlate a failure seen on the frontend with the same request in the backend logs.

The fourth step is to check Redis. If the product detail shows old data, first check if mall:product:detail:v1:{id} exists. If an old JSON exists, it means the read chain hit the cache; next, check if the write chain executed evict after a product update or status change. If Redis has no key but the data is still old, then suspect issues with the database seed, transaction commits, or connection environments.

The fifth step is to check the MySQL query conditions. A record existing in mall_product does not mean it's visible in public details; requirePublishedById also requires status_code = ON_SALE. Similarly, the existence of a category for the category_id is not enough; the category must also be enabled. Many "the database clearly has the data" questions are essentially because business conditions were not met.

The sixth step is to check the DTO assembly. When the endpoint can return but a field is missing, compare ProductDetailResponse and toDetailResponse to confirm whether this field was never exposed, whether it comes from the product table, whether it comes from the category table, or whether it needs format conversion. Don't just look at the Entity, because the Entity is a persistence model, not the API contract.

The diagram below can serve as a sequence table for troubleshooting product detail issues:

flowchart TD
  A[Page shows detail error] --> B{Is the URL the public detail path}
  B -- No --> B1[Correct frontend request address or gateway prefix]
  B -- Yes --> C{Is HTTP 401/403}
  C -- Yes --> C1[Check SecurityConfig whitelist and request path]
  C -- No --> D[Record code and traceId]
  D --> E{Suspected stale data}
  E -- Yes --> E1[Check Redis key and post-write evict]
  E -- No --> F[Check MySQL product status ON_SALE]
  F --> G[Check if category exists and is enabled]
  G --> H[Check DTO assembly and field contract]
  H --> I[Use traceId to return to logs and confirm the full chain]

The value of this checklist is not just for solving product detail problems. Later, when you troubleshoot order creation, payment callbacks, or shopping cart anomalies, you can follow the same backend thinking: first entry, then permissions, then business code, then cache, then database, then DTO, then logs. Frontend troubleshooting often starts from component state; backend troubleshooting often starts from the request chain. The commonality is not to skip steps based on intuition, but to have evidence for each step.

8.12 Using Tests to Reverse-Understand Chain Rules

When learning backend source code, besides reading down from the Controller, there's another very effective method: read the tests first, then return to the implementation. Tests usually express "what exactly this endpoint promises" more directly. For example, ProductControllerTest.shouldExposeOnlyOnSaleProductsToAnonymousUsers is not testing a specific SQL writing style, but testing that the public list only exposes listed products; ProductControllerTest.shouldHideDraftProductDetailFromAnonymousUsers is not testing whether product 2 exists in the database, but testing that a draft product, even if it exists, cannot be obtained by an anonymous user through the public detail endpoint.

From a frontend perspective, tests are like the interaction use cases you write for components: does a modal appear after a user clicks a button, does an error message appear after entering illegal content. Backend tests focus on another type of interaction: after an anonymous request to a certain URL, do the status code, business code, response data, database changes, and cache changes conform to the rules. You don't need to be able to write very complex tests right away, but you should learn to identify business boundaries through tests.

Taking product details as an example, tests can be understood in three layers. The first layer is Controller layer tests, proving the HTTP contract hasn't changed: the path, method, status code, JSON fields, and anonymous access rules all meet expectations. The second layer is Facade layer tests, proving the business orchestration hasn't changed: no database query on a cache hit, database query and cache write on a cache miss, and conversion to 404 on a null value hit. The third layer is CacheService layer tests, proving the Redis key, TTL, null value markers, and degradation statuses haven't changed.

flowchart LR
  A[ControllerTest verifies HTTP contract] --> B[ProductFacadeCacheTest verifies business orchestration]
  B --> C[ProductDetailCacheServiceTest verifies Redis behavior]
  C --> D[RedisContainerTest verifies real Redis connection]

The benefit of this layered testing is quick problem location. Suppose a change causes the public detail to return a draft product to an anonymous user. If the Controller test fails, it means the external contract was broken; if the Facade test fails, it means the business status filtering or cache branch was broken; if the CacheService test fails, it means the cache read/write format or exception degradation was broken. Test names themselves are documentation; by understanding these names, you can infer which rules the project author wanted to protect.

As a frontend-to-backend learner, you can first not pursue coverage metrics but develop two habits: first, before reading source code, find the test with the same name and see what behaviors the test describes; second, before changing any business rule, think "which test should be added or modified to protect this rule." This way, you'll move faster from "can write an endpoint" to "can maintain endpoint contracts."

8.13 Why This Chapter Didn't Have You Directly Write a New Endpoint

Many tutorials will have you add a new endpoint right after explaining the Controller, but Chapter 8 of this series chooses to first completely read the product detail chain because the most important thing in backend learning is not memorizing a specific annotation, but building a sense of the chain. Without a sense of the chain, you might know how to use @GetMapping, @PathVariable, lambdaQuery, and RedisTemplate individually, but not know how they collaborate in a real request; you might also be able to write a working endpoint, but not know which layer permissions, caching, exceptions, DTOs, and logs should be placed in.

The product detail endpoint in this project is small enough and complete enough. It doesn't have the complex transactions of order payments or the high concurrency risks of inventory deductions, but it already contains most of the elements necessary for backend beginners to understand: public endpoints, path parameters, unified responses, business exceptions, enum statuses, MySQL queries, related table validation, Redis caching, null value caching, degraded fallback, DTO conversion, and traceId. Once you are familiar with reading this one chain, looking at adding products, listing/delisting, shopping carts, and orders won't feel like each class is isolated.

You can treat this chapter as a "source code walkthrough training." The real standard for mastery is not being able to recite every method name, but when given a phenomenon, knowing where to start investigating; when given a requirement, knowing whether to change the Controller, Facade, Service, Mapper, DTO, exception code, or test. Starting from the next chapter, when we enter write operations, this judgment will be even more important, because if a write operation is mishandled, it's not just a page display error, but could cause dirty data, cache inconsistency, or unauthorized modifications.

9. Chapter Exercises

Exercise 1: Manually Draw the Successful Chain for /api/products/1

Starting from TraceIdFilter, sequentially draw Security, Controller, Facade, CacheService, Redis, ProductDbService, CategoryDbService, MySQL, DTO, ApiResponse. Mark which steps might skip MySQL.

Exercise 2: Explain Why Product 2 Returns a 404 on Public Detail

Read the status_code of product 2 in sql/02_seed.sql, then read ProductDbService.requirePublishedById. Explain in your own words: why does the database have this record, but /api/products/2 still returns PRODUCT_NOT_FOUND?

Exercise 3: Write a Frontend Type Based on the DTO

Based on ProductDetailResponse.java, write a TypeScript interface for "frontend-side illustrative code." Note that createdAt and updatedAt are usually strings in JSON, not frontend Date objects.

// Frontend-side illustrative code: Write a type based on the backend DTO
interface ProductDetail {
  id: number
  categoryId: number
  categoryName: string
  title: string
  subtitle: string
  description: string | null
  status: 'DRAFT' | 'ON_SALE' | 'OFF_SHELF'
  createdBy: number
  createdAt: string
  updatedAt: string
}

Exercise 4: Verify Cache Status

Request /api/products/1 twice, observing MISS, PUT, HIT in the logs. Then delete the Redis key and request once more, confirming it returns to MISS.

Exercise 5: Verify Null Value Cache

Request /api/products/999999 twice, and use redis-cli get mall:product:detail:v1:999999 to view the null value marker. Explain why the null value cache TTL is shorter than the normal detail TTL.

Exercise 6: Find Backend Logs from the Response traceId

Use curl with X-Trace-Id: product001trace to request the product detail. Then search for this traceId or cache events in the backend logs, practicing a frontend-backend joint debugging investigation.

Exercise 7: Find Tests That Prove Chain Rules

Read ProductControllerTest.shouldHideDraftProductDetailFromAnonymousUsers and explain which business rule it verifies. Then read ProductFacadeCacheTest.shouldQueryDatabaseAndFillCacheOnMiss and explain which caching rule it verifies.

10. A Bit Deeper: Why the List and Detail Endpoints Aren't Exactly the Same

Also in ProductController, the public list endpoint is:

@GetMapping
public ApiResponse<PageResponse<ProductSummaryResponse>> queryProducts(...)

It calls productFacade.queryPublishedProducts(request). The detail endpoint is:

@GetMapping("/{id}")
public ApiResponse<ProductDetailResponse> detail(...)

It calls productFacade.getPublishedProduct(id). Both only target publicly listed products, but their caching strategies differ. The current project only adds Redis caching to the detail endpoint, not the list endpoint. Why? Because the list has more query conditions: page number, page size, keywords, and categories can all change; the list also involves sorting and pagination, making the overall key design more complex. The detail endpoint has only one stable ID, making it more suitable as an introductory caching case.

There's another point worth learning in the list endpoint: toPageResponse first collects all categoryIds from the product pagination results, then uses categoryDbService.findCategoryMap(categoryIds) to batch query categories in one go, avoiding an N+1 query problem where each product queries the category individually. The detail endpoint only queries one product, so querying the category once directly is sufficient.

This shows that backend performance optimization isn't just about Redis. Batch querying, indexing, pagination, avoiding N+1, reducing unnecessary fields, and proper DTO assembly are all parts of performance optimization. Redis is a very important tool, but not every endpoint should have caching added immediately.

11. Next Chapter Preview: Product Creation, Listing, Delisting, and State Machines

This chapter completely read through the query chain for public product details. The next chapter will turn to admin-side write operations: product creation, listing, delisting, and state machines.

The biggest difference between write and read operations is: read operations mainly care about "how to query fast and accurately, and how errors are returned"; write operations additionally care about "who can write, what status can be written to, what to validate before writing, which caches to affect after a successful write, and whether partial success is possible on failure." In the current project, an admin creating a product can only create a DRAFT, which cannot be directly forged as ON_SALE by the frontend; before listing a product from draft, the category and saleable SKU must be validated; product status can only transition according to the allowed state machine; after a status change succeeds, the product detail cache must also be deleted.

In the next chapter, you'll see that backend state machines and frontend button states are not the same thing. The frontend can disable buttons to improve the experience, but the real prevention of illegal state transitions must be on the backend. We will continue reading segment by segment based on ProductAdminController, ProductFacade.changeStatus, ProductStatus, SkuDbService.requireSaleableSku, and the cache invalidation logic.

12. Chapter Summary

For this chapter, you need to take away 10 core conclusions:

  1. A backend endpoint is not just a Controller method, but a chain composed of Filter, Security, Controller, Facade, cache, database, exception handling, and JSON serialization;
  2. /api/products/{id} allows anonymous access because SecurityConfig explicitly does permitAll for the whitelist /api/products/**;
  3. Anonymous access does not mean returning all products; public details are forced to ON_SALE by ProductDbService.requirePublishedById;
  4. The product detail response is not a single-table result; categoryName comes from the category table and must be assembled via CategoryDbService.requireEnabledCategory;
  5. Redis is a performance layer; HIT can be returned directly, while MISS, DEGRADED, and BYPASS must all fall back to MySQL;
  6. NULL_HIT is a short TTL null value cache used to quickly restore a stable 404 as a business exception;
  7. ProductDetailResponse is a frontend-facing DTO; ProductEntity should not be directly exposed;
  8. Successful responses are uniformly wrapped by ApiResponse.success, containing code, message, data, traceId, timestamp;
  9. Business exceptions are uniformly converted to error JSON by GlobalExceptionHandler; the frontend should focus on the stable code and traceId;
  10. When troubleshooting product detail issues, check layer by layer along the chain: URL and permissions, path parameters, cache status, product status, category status, DTO assembly, exception response.

If you can trace from /api/products/1 to mall_product, mall_category, and the Redis key mall:product:detail:v1:1 without looking at the documentation, and can explain why /api/products/2 returns a 404, it means you have acquired the basic ability to read a real backend request chain.