When designing or modifying REST API response structures, prevent common design flaws.
Pitfall #1: Generic Method Overload Ambiguity
Scenario: When the return type is String, Java overload resolution may match the wrong method
Root Cause
During Java method overload resolution, aString type parameter will preferentially match success(String message) rather than success(T data), causing data to go into the wrong field.
Incorrect Example
// ApiResponse 有两个重载:
public static <T> ApiResponse<T> success(T data)
public static <T> ApiResponse<T> success(String message, T data)
// ❌ 错误: String 类型匹配到 success(String message)
String avatarUrl = "http://example.com/avatar.jpg";
return ApiResponse.success(avatarUrl);
// 结果: {"code":200, "message":"http://...", "data":null}
// 前端 data.data 拿到 null,导致功能异常
Correct Approach
// ✅ 方案1: 明确指定 message 参数(推荐)
return ApiResponse.success("上传成功", avatarUrl);
// 结果: {"code":200, "message":"上传成功", "data":"http://..."}
// ✅ 方案2: 使用泛型明确类型
return ApiResponse.<String>success(avatarUrl);
// ✅ 方案3: 包装为 DTO(复杂场景推荐)
return ApiResponse.success(new UploadResult(avatarUrl));
Checklist
- When the return type is String, is the message parameter explicitly specified
- Are there both single-parameter and two-parameter overloaded methods
- Does the frontend correctly parse the
datafield (rather thanmessage) - Are there unit tests verifying the response structure
Pitfall #2: Unclear Response Field Semantics
Scenario: message and data field responsibilities are confused
Specification
| Field | Purpose | Type | Example |
|---|---|---|---|
code |
Business status code | int | 200, 400, 500 |
message |
User-readable prompt message | String | "Upload successful", "Invalid parameter" |
data |
Business data | T | {"url": "..."}, [...] |
timestamp |
Response timestamp | String | ISO 8601 format |
Incorrect Example
// ❌ 错误: 把业务数据放在 message
return ApiResponse.success("avatars/2026-04/xxx.jpeg");
// ❌ 错误: message 包含技术细节
return ApiResponse.error("NullPointerException at line 42");
Correct Approach
// ✅ message 是用户提示,data 是业务数据
return ApiResponse.success("上传成功", avatarUrl);
// ✅ 错误信息对用户友好
return ApiResponse.error("文件格式不支持,请上传 JPG/PNG 格式");
Pitfall #3: Inconsistent Null Handling
Scenario: When there is no data, returning null、{}、[] is not consistent
Specification
| Scenario | Recommended Return | Description |
|---|---|---|
| Single object does not exist | data: null |
Frontend checks if (!data) |
| List is empty | data: [] |
Frontend can iterate directly |
| Paginated data is empty | data: {list: [], total: 0} |
Keep the structure consistent |
Incorrect Example
// ❌ 错误: 有时返回 null,有时返回空对象
if (user == null) {
return ApiResponse.success(null); // 不一致
}
return ApiResponse.success(new UserVO());
Correct Approach
// ✅ 统一返回 null 表示不存在
if (user == null) {
return ApiResponse.success(null);
}
return ApiResponse.success(userVO);
// ✅ 列表统一返回空数组
List<UserVO> users = userService.list();
return ApiResponse.success(users); // 永远不返回 null
Pitfall #4: Confusing HTTP Status Codes with Business Status Codes
Scenario: Returning HTTP 500 when the business operation fails
Specification
| Scenario | HTTP Status Code | Business code | Description |
|---|---|---|---|
| Success | 200 | 200 | Normal response |
| Invalid parameter | 200 | 400 | Business-layer validation failed |
| Unauthorized | 401 | - | Authentication failed |
| No permission | 403 | - | Authorization failed |
| Resource does not exist | 200 | 404 | Business resource does not exist |
| Server error | 500 | - | Code exception |
Incorrect Example
// ❌ 错误: 业务失败返回 HTTP 500
if (user == null) {
throw new RuntimeException("用户不存在"); // HTTP 500
}
Correct Approach
// ✅ 业务失败返回 HTTP 200 + 业务 code
if (user == null) {
return ApiResponse.error(404, "用户不存在"); // HTTP 200
}
// ✅ 只有代码异常才返回 HTTP 500
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<?>> handleException(Exception e) {
log.error("服务器错误", e);
return ResponseEntity.status(500)
.body(ApiResponse.error("服务器错误,请稍后重试"));
}
Pitfall #5: Lack of Integrity Validation Before Generating Downstream Artifacts (Appears Successful, Downstream Fails Silently)
Scenario: Excel/CSV/PDF export, reconciliation file generation, third-party API push, batch notifications, and other functions that "produce downstream consumption artifacts": when key fields are missing, they are backfilled with empty strings/null/default values, the file can be generated and the API can return 200, but downstream consumers (WeChat import, reconciliation system, third-party platforms) will fail silently or reject it.
Root Cause
The business side focuses on "the API returned 200" and "the file was generated successfully," but the real success criterion lies with thedownstream consumer:
- The WeChat shipping template requires
mchIdandtransactionIdto be present; if missing, it will be rejected - The reconciliation file requires each row to have a complete
订单号 / 金额 / 时间; if any item is missing, the entire batch is rejected - Logistics push requires
快递公司and快递单号to both be present, otherwise the logistics side reports an error
If missing values are backfilled with "" / null / 0 during generation, the caller will think it succeeded and only start troubleshooting after downstream consumption fails, wasting time and affecting the business.
Incorrect Example
// ❌ 错误: 关键字段缺失时静默用空值兜底,Excel 能生成但微信导入失败
public byte[] export(Long tenantId, List<Long> orderIds) {
String mchId = miniAppConfigRepository.findByTenantId(tenantId)
.map(TenantMiniAppConfig::getMchId)
.orElse("");
Map<Long, PaymentRecord> paymentMap = loadPaymentMap(tenantId, orders);
for (TradeOrder order : orders) {
PaymentRecord payment = paymentMap.get(order.getId());
String transactionId = payment != null ? payment.getTransactionId() : "";
String company = packages.stream().map(ShippingPackage::getCompany)
.filter(StringUtils::hasText).distinct().collect(joining(";"));
// transactionId 为空仍会写入 Excel,微信发货模板按位置导入时会拒收
writeRow(sheet, order, mchId, transactionId, company, ...);
}
return workbook.toByteArray();
}
Correct Approach
"Draw all required fields at once + pre-validate before generation + list business IDs for missing items + fail as a whole"
public byte[] export(Long tenantId, List<Long> orderIds) {
TenantMiniAppConfig config = miniAppConfigRepository.findByTenantId(tenantId)
.orElseThrow(() -> new BusinessException("未配置小程序"));
if (!StringUtils.hasText(config.getMchId())) {
throw new BusinessException("小程序未配置微信支付商户号,无法导出");
}
Map<Long, PaymentRecord> paymentMap = loadPaymentMap(tenantId, orders);
validateRequiredFields(orders, paymentMap);
return buildWorkbook(orders, paymentMap, config.getMchId());
}
private void validateRequiredFields(List<TradeOrder> orders, Map<Long, PaymentRecord> paymentMap) {
List<String> missingTxn = new ArrayList<>();
List<String> missingPackage = new ArrayList<>();
List<String> missingCompany = new ArrayList<>();
for (TradeOrder order : orders) {
PaymentRecord payment = paymentMap.get(order.getId());
if (payment == null || !StringUtils.hasText(payment.getTransactionId())) {
missingTxn.add(order.getOrderNumber());
}
List<ShippingPackage> packages = resolvePackages(order);
if (packages.isEmpty()) {
missingPackage.add(order.getOrderNumber());
} else if (packages.stream().anyMatch(p -> !StringUtils.hasText(p.getCompany()))) {
missingCompany.add(order.getOrderNumber());
}
}
if (!missingTxn.isEmpty()) {
throw new BusinessException("以下订单缺少微信支付交易单号,无法导出: " + String.join("、", missingTxn));
}
if (!missingPackage.isEmpty()) {
throw new BusinessException("以下订单缺少快递单号,无法导出: " + String.join("、", missingPackage));
}
if (!missingCompany.isEmpty()) {
throw new BusinessException("以下订单缺少快递公司,无法导出: " + String.join("、", missingCompany));
}
}
Three levels of required fields (draw them all at once to avoid needing multiple review rounds to complete them)
Before starting,first list the complete checklist; otherwise, one is discovered per review round, leading to repeated rework like "fixed mchId/transactionId, and only in the next round discovered the courier company/tracking number":
| Level | Meaning | Example |
|---|---|---|
| Business fields | Required for each business record itself | Order number, amount, quantity, order status |
| Dependent configuration | Global configuration required for generating the entire artifact | mchId, API credentials, template ID, signing key |
| Foreign key associations | Other entities that the business record must be associated with | Associated payment record (and the transaction number is non-empty), logistics order (and both the company + tracking number are present), recipient address |
The basis for determining "required" comes from the downstream consumer's documentation(WeChat Open Platform import documentation, reconciliation specifications, third-party push API spec), not from whether the source data is convenient to fill in.
Checklist
- Which fields are required by the downstream consumer (WeChat, reconciliation, third-party API), and have their documentation/specifications been consulted
- Have all paths where these fields may be empty in the source data been clearly identified (points payment, mock data, unconfigured, missing foreign keys)
- When missing, is a clear business error returned (including the specific business ID) instead of backfilling with an empty string/null/0
- Does the error message include a locatable business identifier (order number/batch number/external order number)
- Are all three levels of required fields (business fields + dependent configuration + foreign key associations) covered
Multilingual Examples
For complete Java (POI)/Go (excelize)/TypeScript (exceljs) implementation examples, see references/multi-lang-examples.md。
Checklist (API Design)
Return value design:
- When returning a String type, is the message parameter explicitly specified
-
messageDoes the field contain only user-readable prompt information -
dataDoes the field contain only business data - Is null handling consistent (null / [] / {})
Status code design:
- Are HTTP status codes used only for the transport layer (200/401/403/500)
- Is the business status code in the response body's
codefield - On business failure, is HTTP 200 + business code returned
Frontend-backend contract:
- Does the frontend correctly parse the
datafield - Does the frontend handle the
data: nullcase - Is there API documentation or interface tests
Artifact completeness(when generating Excel/CSV/PDF/reconciliation files/batch pushes, see Pitfall #5):
- Are the three levels of required fields (business fields + dependency configuration + foreign key associations) fully mapped out in one go
- Is the basis for required-field determination taken from the downstream consumer's documentation
- When missing, is a business error thrown (with the specific business ID) rather than falling back to a null value
- Does the error message contain a business identifier that can be used to locate the issue
Pitfall #6: Misusing framework exceptions for business errors → everything becomes 500
Scenario: In the Service, throw new IllegalArgumentException("系统分类不可用") is used to express a business error, and the frontend gets 500 + a pile of stack trace.
Root cause
Spring's default global exception handling treats IllegalArgumentException / IllegalStateException / RuntimeException and otherframework/JDK exceptionsall as "code bugs" and returns HTTP 500 + a generic error.
Projects usually have a custom BusinessException(or BizException), and the global handler processes it into HTTP 200 + business code 4xx + a user-friendly message.
Using the wrong exception type → the frontend cannot get the correct message, users cannot see the reason, and monitoring alerts are flooded by business errors.
Incorrect example
// ❌ 框架异常被全局 handler 处理成 500
public void updateMapping(Long id, Long categoryId) {
ProductCategory category = repository.findById(categoryId)
.orElseThrow(() -> new IllegalArgumentException("系统分类不存在"));
if (category.getStatus() != ACTIVE) {
throw new IllegalArgumentException("系统分类不可用"); // 500 + 堆栈
}
}
Correct approach
// ✅ 用项目自定义业务异常
public void updateMapping(Long id, Long categoryId) {
ProductCategory category = repository.findById(categoryId)
.orElseThrow(() -> new BusinessException("系统分类不存在"));
if (category.getStatus() != ACTIVE) {
throw new BusinessException("系统分类不可用"); // 200 + code 4xx + 友好 message
}
}
Exception type comparison table
| Scenario | Which exception to throw | Global handler handling | HTTP status |
|---|---|---|---|
| Business rule not satisfied (amount exceeded, status not allowed) | BusinessException |
200 + code 4xx + business message | 200 |
| Parameter format error (manual validation) | BusinessException or @Valid triggered by MethodArgumentNotValidException |
200 + code 400 + field hint | 200 |
| Resource does not exist | BusinessException or custom NotFoundException |
200 + code 404 + business message | 200 |
| No permission | AccessDeniedException(handled by Spring Security) |
403 | 403 |
| Not authenticated | AuthenticationException(handled by Spring Security) |
401 | 401 |
| Code bug (impossible null, assertion failure) | IllegalStateException / AssertionError |
500 + generic error + alert | 500 |
| External dependency failure (remote API 5xx, DB disconnected) | Let the framework exception bubble up, and the global handler converts it to 500 | 500 + generic error + alert | 500 |
Core principles:
- Business-predictable errors →
BusinessException(users can understand them, should not trigger alerts) - Code bugs / system failures → let framework exceptions bubble up (they should trigger alerts and should be investigated)
- Do not wrap NPEs / state machine violations as business errors just to "uniformly throw BusinessException" — that will cause alerts to miss real bugs
Detection signals
If any of the following is seen during code review, suspect it immediately:
- In the Service,
throw new IllegalArgumentException(...)orthrow new RuntimeException(...)wrap business errors orElseThrow(() -> new IllegalArgumentException(...))Express "resource not found"- The frontend reports 500, but the business interface logic is clearly a user input error (should be 4xx)
- In the global exception handler there is no
BusinessExceptionbranch
Checklist
- When a Service throws a business error, is it using
BusinessException(notIllegalArgumentException) -
orElseThrowwhether to use a business exception - Whether the global exception handler distinguishes
BusinessException(200 + code 4xx) vs other exceptions (500) - Whether the interfaces reporting 500 are really code bugs (rather than business errors being misclassified)
Scope of application
- Java: Spring Boot REST API
- Go: Gin/Echo REST API
- Node.js: Express/Koa REST API
- Python: FastAPI/Flask REST API
Rule traceability
> 📋 本回复遵循:`cc-api-design-safety` - API 设计安全规范