
Ddd Api Designer
- 17 installs
- 1 repo stars
- Updated July 29, 2026
- full-statck-skills/ddd-skills
Designs REST APIs from a DDD domain model with CQRS command/query split, PO-DO-DTO-VO conversion, unified responses, BFF, versioning, and OpenAPI.
About
Guides REST API design from a domain model, covering CQRS separation, the four-layer data-object conversion chain, unified response format, BFF, and API versioning. A developer uses it when exposing DDD aggregates as REST APIs or designing DTO/VO layers.
- CQRS command vs query endpoint separation
- PO-DO-DTO-VO four-layer conversion chain
Ddd Api Designer by the numbers
- 17 all-time installs (skills.sh)
- Ranked #3,475 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 30, 2026 (Skillselion catalog sync)
npx skills add https://github.com/full-statck-skills/ddd-skills --skill ddd-api-designerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 17 |
|---|---|
| repo stars | ★ 1 |
| Last updated | July 29, 2026 |
| Repository | full-statck-skills/ddd-skills ↗ |
What it does
Designs REST APIs from a DDD domain model with CQRS command/query split, PO-DO-DTO-VO conversion, unified responses, BFF, versioning, and OpenAPI.
Files
DDD API Designer
从领域模型到 REST API 的完整设计指南:CQRS 读写分离、四层数据对象转换链(PO→DO→DTO→VO)、统一响应格式、BFF 多端适配、版本管理与安全设计。
Workflow
1. 识别 Command vs Query — 将领域行为分为命令(写)和查询(读),决定 Method 和端点 2. 设计数据对象转换链 — 建立 PO→DO→DTO→VO 四层转换,各层独立职责 3. 设计 REST 端点 — Command 动词后缀, Query 资源命名 4. 定义统一响应格式 — Result<T> 包装 + 业务错误码体系 5. 应用 BFF — 每前端一个 BFF, 数据聚合 + 格式适配 + 协议转换 6. 选择版本策略 — 推荐 URL Path: /api/v1/orders, CDN 友好 7. 施加安全控制 — AuthN + AuthZ + 三层校验 + 差异化限流
When to Use
| ✅ ALWAYS use when | ❌ Skip when |
|---|---|
| API 设计、REST API、接口设计 | 内部工具无外部消费者 |
| DTO/VO 设计、数据对象转换 | GraphQL/gRPC 项目 |
| BFF / Backend for Frontend | 无领域模型时 → domain-designer |
| OpenAPI / Swagger / API 文档 | 简单 CRUD 无 DDD |
| API 版本管理 / 安全设计 | 纯 gRPC 微服务(用 protobuf IDL) |
| 需要将 DDD 聚合暴露为 REST API | 快速原型不关心 API 规范 |
Boundary
✅ 明确适用
- 需要将 DDD 领域模型暴露为 REST API — CQRS 读写分离、数据对象转换链完整落地
- CQRS 命令/查询分离设计 — 独立 Command DTO 和 Query DTO,各自演化
- 多端(Web/iOS/MiniApp)API 统一设计 — BFF 模式按平台适配
- 统一响应格式与错误码体系设计 — Result<T> + 业务错误码标准化
- OpenAPI/Swagger 规范输出 — 代码生成策略保持接口与实现同步
⚠ 需谨慎评估
- 团队对 DDD/CQRS 不熟悉 → 先学习基础概念
- 单体应用无扩展需求 → 评估 ROI,可能过度设计
- 现有 API 无消费者兼容需求 → 版本管理可简化
❌ 不适用
- GraphQL/gRPC 项目 → 使用对应 IDL 和工具链
- 简单 CRUD 无 DDD → 先用通用 REST 框架或 domain-designer
- 快速原型/演示阶段 → 先用简化 API,后续再引入规范
- 纯 gRPC 微服务 → 使用 protobuf IDL + gRPC 拦截器
- 内部工具无外部消费者 → 简化 API 设计
CQRS API Design
Command(写)动词驱动,Query(读)资源驱动:
| 维度 | Command | Query |
|---|---|---|
| HTTP Method | POST/PUT/DELETE | GET |
| URL 动词 | 需要(confirm, cancel) | 不需要 |
| 请求体 | Command 对象 | 仅查询参数 |
| DTO 分离 | 独立 Command DTO | 独立 Query DTO |
| 幂等性 | 必须实现 | 天然幂等 |
| 缓存 | 从不缓存 | ETag, max-age |
| 响应 | 创建的资源摘要 | 数据 DTO / 列表 |
原则:Command DTO 和 Query DTO 始终分开定义。子资源嵌套最多 2 层。详见 references/patterns/cqrs-api-design.md
数据对象转换链(PO → DO → DTO → VO)
| 对象 | 层 | 职责 | 可见性 |
|---|---|---|---|
| PO | Infrastructure | ORM 映射,数据库结构对应 | 内部 |
| DO | Domain | 充血模型,含业务行为 | 内部 |
| DTO | Interface/App | 跨层跨服务数据传输 | 半内部 |
| VO | Interface | 页面专用展示数据 | 外部 |
读方向:PO→DO→DTO→VO;写方向:VO→DTO→Command→DO→PO。 一个 DO 可按场景转换为多个 DTO(详情 DTO、摘要 DTO 等),Controller 不直接返回领域对象。详见 references/examples-ref/data-object-transformation.md
API 设计规范
| 规则 | 示例 |
|---|---|
| 名词复数 | /orders ✓ |
| Kebab-case | /order-history ✓ |
| 最大 2 层嵌套 | /orders/{id}/items |
| 写动词后缀 | /orders/{id}/confirm |
| 查询参数 | ?status=PAID&page=1 |
| 无 URL 动词 | ❌ GET /getOrders → GET /orders |
HTTP Status:201 Created(创建)、200 OK(查询/更新)、204 No Content(删除)、400(校验/业务)、404(未找到)、409(并发冲突)、429(限流)、500(内部错误)。详见 references/security/api-naming-conventions.md
统一响应格式
成功:{ "code": 0, "message": "success", "data": T } — 201/200/204 错误:{ "code": 40001, "message": "...", "detail": "...", "requestId": "req-xxx" } — 400/404/409/429/500
Response wrapper Result<T> 包含 code + message + data + requestId。错误响应绝不返回堆栈信息。详见 references/examples-ref/unified-response-format.md
BFF(Backend for Frontend)
每前端一个 BFF(Web/iOS/MiniApp),职责:
- 数据聚合:组合多服务数据为页面 VO(1 次前端调用替代 N 次)
- 格式适配:Web 全量字段 / 移动端精简字段
- 协议转换:内部 gRPC → 外部 REST/JSON
- 响应塑形:移除内部字段,添加 UI 元数据
与 API Gateway 区别:BFF 做视图聚合(页面级),Gateway 做路由+限流(服务级)。 BFF 不直接访问数据库,不包含业务逻辑。详见 references/patterns/BFF-design-pattern.md
API 版本管理
| 策略 | 示例 | 推荐度 |
|---|---|---|
| URL Path ★ | /api/v1/orders → /api/v2/orders | ★★★★★ |
| Request Header | Accept: vnd.company.v2+json | ★★★☆☆ |
| Query Param | /api/orders?version=2 | ★★☆☆☆ |
推荐 URL Path:直观、CDN 友好、Swagger 兼容。迁移流程:v1 → v1+v2 → v2 only → v1 sunset(410 Gone)。详见 references/migration/api-versioning-strategies.md
API 安全设计
四层安全模型: 1. 认证:JWT Bearer Token / OAuth2 / API Key(服务间) 2. 授权:按限界上下文 + 资源所有权 + 角色 3. 输入校验:Controller 格式 → Application 业务 → Domain 不变式 4. 限流:Command 50/s, Query 200/s, Auth 10/s。详见 references/security/api-security-design.md
Gotchas — 常见陷阱
DTO 暴露枚举→string code | Command/Query DTO 混用→分开 | null 安全→处理 Optional | VO 透传 DB 字段→视图定制 | 幂等缺失→Idempotency-Key | 错误透传堆栈→requestId | 深层嵌套→≤2 层 | 领域对象序列化→经 DTO/VO
Rules
- Command/Query DTO 分离 — 写操作和读操作使用独立 DTO,禁止复用同一结构
- Controller 协议转换 — Controller 层仅做 HTTP 协议适配,不包含业务逻辑或领域调用
- 统一错误码前缀 — 业务错误 5 位码:首位类别(4=客户端/5=服务端)+ 后两位 HTTP + 末三位具体错误
- BFF 职责边界 — BFF 只做数据聚合与格式适配,不直接访问数据库或不包含业务规则
- 响应封装 — 所有 API 响应使用 Result<T> 包装,仅 204 No Content 和文件下载可例外
FAQ
| Question | Answer |
|---|---|
| DO 和 DTO 字段一样能复用吗? | 不能。DO 含行为,DTO 纯数据,演化方向不同。 |
| 所有 API 都要统一响应格式? | 是,仅文件下载、204 可例外。 |
| 错误码怎么设计? | 5 位数字:首位类别+后两位 HTTP+末三位具体错误。 |
| 何时需要 BFF? | 多前端平台或前端需组合多服务数据。 |
| 子资源最多嵌套几层? | 最多 2 层,超 2 层说明聚合边界有问题。 |
| Controller 中能放业务逻辑吗? | 不能,只做协议转换。 |
Keywords
CQRS API REST endpoint design PO DO DTO VO data object transformation unified response format BFF Backend for Frontend OpenAPI Swagger API versioning API security command query separation Result<T> response wrapper input validation rate limiting idempotency pagination design
References
- references/patterns/cqrs-api-design.md — CQRS API 设计
- references/examples-ref/data-object-transformation.md — PO↔DO↔DTO↔VO 转换
- references/patterns/BFF-design-pattern.md — BFF 设计模式
- references/security/api-security-design.md — API 安全
- references/migration/api-versioning-strategies.md — 版本管理
- references/security/api-naming-conventions.md — 命名规范
- references/examples-ref/unified-response-format.md — 统一响应
- references/security/openapi-specification.md — OpenAPI 3.0 规范
- references/patterns/data-access-api.md — 数据访问层 API 设计
- references/patterns/idempotency-design.md — 幂等设计
- references/patterns/pagination-filtering-design.md — 分页过滤
- references/architecture/partme-16-service-data-view.md — 协作关系
- references/architecture/clean-ddd-hexagonal-hexagonal.md — 六边形架构
- references/examples-ref/api-error-handling.md — 错误处理
- references/security/api-rate-limiting.md — 限流设计
- references/examples-ref/event-driven-api.md — 事件驱动 API
Examples
- examples/order-api-design.md — 订单服务案例
- examples/user-api-design.md — 用户服务案例:注册/登录/资料 + 安全设计
- examples/BFF-aggregation-example.md — BFF 聚合案例:订单详情页多服务数据聚合
- examples/api-version-migration.md — API 版本迁移案例:v1 → v2 全流程
- examples/payment-api-design.md — 支付服务 API 案例:退款幂等、状态机、异步通知
- examples/openapi-codegen-example.md — OpenAPI 代码生成案例:Spec-first 策略
- examples/product-api-design.md — 商品服务 API 案例
- examples/inventory-api-design.md — 库存服务 API 案例
- examples/notification-api-design.md — 通知服务 API 案例
- examples/search-api-design.md — 搜索服务 API 案例
---
🧭 DDD Skills Journey
📍 当前:`ddd-api-designer` — Step 4: API 设计与数据转换
Step 1 (awesome) → Step 2 (selector) → Step 3 (架构落地) → Step 4 (领域+CQRS+API) → Step 5 (审查) → Step 6 (辅助) → Step 7 (文档)
↑
⭐ ddd-api-designer: 领域模型 → REST API← 上一站: ddd-domain-designer — 先有领域模型再设计 API → 下一站: ddd-code-reviewer — 审查 API 设计合规性 🔗 相关: ddd-cqrs-architecture — CQRS 深入 | ddd-architecture-doc — OpenAPI 文档输出
核心原则:Command 和 Query 分开设计。牢记 PO→DO→DTO→VO 四层转换链,DTO 与领域对象解耦,VO 与数据库结构解耦。
BFF 聚合案例:订单详情页
演示 BFF 层如何将多个微服务的数据聚合到一个页面专用的 VO。
场景
订单详情页需要展示以下数据:
┌─ 订单详情页 ──────────────────────────────────────────┐
│ │
│ 订单信息: #ORD-2024-001 │
│ 状态: 已支付 | 金额: ¥99.00 │
│ │
│ ├─ 商品清单: │
│ │ 1. T-Shirt × 2 — ¥49.50/件 │
│ │ 2. 牛仔裤 × 1 — ¥199.00/件 │
│ │ │
│ ├─ 支付信息: │
│ │ 支付方式: 微信支付 │
│ │ 支付时间: 2024-01-15 10:30 │
│ │ │
│ ├─ 物流信息: │
│ │ 快递单号: SF-1234567890 │
│ │ 预计送达: 2024-01-20 │
│ │ │
│ └─ 操作按钮: [取消订单] [申请退款] [查看物流] │
└────────────────────────────────────────────────────────┘无 BFF 的痛点
前端需要调用 4 个不同的 API:
1. GET /api/v1/orders/{id} → Order Service
2. GET /api/v1/orders/{id}/items → Order Service (or nested)
3. GET /api/v1/payments/order/{id} → Payment Service
4. GET /api/v1/shipping/order/{id} → Shipping Service
问题:
- 4 次 HTTP 调用,延迟叠加
- 前端需要处理部分失败(某个服务挂了)
- 前端需要自己组合数据
- 每个页面都重复这种组合逻辑BFF 聚合方案
BFF 端点
GET /api/web-bff/order-detail/{orderId}BFF 内部调用链
BFF OrderDetailService
│
├── (并行) → Order Service (gRPC) → OrderDetailDO
│ getOrder(orderId)
│
├── (并行) → Payment Service (gRPC) → PaymentDTO
│ getPaymentByOrder(orderId)
│
├── (并行) → Shipping Service (gRPC) → ShippingDTO
│ getShippingByOrder(orderId)
│
└── 组装 → OrderDetailVO (返回给前端)BFF 服务实现
@Service
public class OrderDetailBffService {
private final OrderServiceClient orderClient;
private final PaymentServiceClient paymentClient;
private final ShippingServiceClient shippingClient;
public OrderDetailVO getOrderDetail(String orderId) {
// 并行调用三个微服务
CompletableFuture<OrderDetailDO> orderFuture =
CompletableFuture.supplyAsync(() -> orderClient.getOrder(orderId));
CompletableFuture<PaymentDTO> paymentFuture =
CompletableFuture.supplyAsync(() -> paymentClient.getPaymentByOrder(orderId));
CompletableFuture<ShippingDTO> shippingFuture =
CompletableFuture.supplyAsync(() -> shippingClient.getShippingByOrder(orderId));
// 等待所有调用完成(带超时)
CompletableFuture.allOf(orderFuture, paymentFuture, shippingFuture)
.get(3, TimeUnit.SECONDS);
// 组装 VO
OrderDetailDO order = orderFuture.get();
PaymentDTO payment = paymentFuture.get(); // 可能为 null(未支付)
ShippingDTO shipping = shippingFuture.get(); // 可能为 null(未发货)
return OrderDetailVO.builder()
.orderId(order.getOrderId())
.status(order.getStatus())
.statusText(getStatusText(order.getStatus()))
.totalAmount(order.getTotalAmount())
.items(order.getItems().stream()
.map(this::toItemVO)
.toList())
.payment(payment != null ? toPaymentVO(payment) : null)
.shipping(shipping != null ? toShippingVO(shipping) : null)
.actions(determineActions(order.getStatus()))
.build();
}
private List<String> determineActions(OrderStatus status) {
return switch (status) {
case DRAFT -> List.of("pay", "cancel");
case PAID -> List.of("cancel", "apply_refund");
case SHIPPED -> List.of("track", "confirm_receipt");
case DELIVERED -> List.of("review", "apply_return");
case CANCELLED -> List.of("reorder");
};
}
}BFF VO 定义
// BFF 返回的前端 VO
GET /api/web-bff/order-detail/ORD-2024-001 → 200
{
"code": 0,
"message": "success",
"data": {
"orderId": "ORD-2024-001",
"status": "PAID",
"statusText": "已支付",
"totalAmount": "¥298.00",
"items": [
{ "productName": "T-Shirt", "imageUrl": "https://cdn.com/tshirt.jpg",
"quantity": 2, "unitPrice": "¥49.50", "subtotal": "¥99.00" },
{ "productName": "牛仔裤", "imageUrl": "https://cdn.com/jeans.jpg",
"quantity": 1, "unitPrice": "¥199.00", "subtotal": "¥199.00" }
],
"payment": {
"method": "微信支付",
"amount": "¥298.00",
"paidAt": "2024-01-15T10:30:00"
},
"shipping": null,
"actions": ["cancel", "apply_refund"],
"createdAt": "2024-01-15T10:30:00",
"ui": {
"pageTitle": "订单详情",
"primaryAction": "取消订单",
"primaryActionColor": "red"
}
}
}部分失败处理
public OrderDetailVO getOrderDetailSafe(String orderId) {
OrderDetailDO order = null;
PaymentDTO payment = null;
ShippingDTO shipping = null;
List<String> warnings = new ArrayList<>();
try {
order = orderClient.getOrder(orderId);
} catch (Exception e) {
// Order service is critical — fail the whole request
throw new BffException("订单服务暂时不可用", e);
}
try {
payment = paymentClient.getPaymentByOrder(orderId);
} catch (Exception e) {
warnings.add("支付信息暂时不可用");
}
try {
shipping = shippingClient.getShippingByOrder(orderId);
} catch (Exception e) {
warnings.add("物流信息暂时不可用");
}
OrderDetailVO vo = assembleVO(order, payment, shipping);
vo.setWarnings(warnings);
return vo;
}BFF 响应超时策略
| 策略 | 实现 | 适用场景 |
|---|---|---|
| Wait All | CompletableFuture.allOf().get(timeout) | 核心数据 |
| Wait Fast | 先返回已就绪的数据,慢的异步补充 | 非关键数据 |
| Fallback | 下游超时时返回默认值 | 可选数据 |
| Circuit Break | 下游连续失败后快速失败 | 防止雪崩 |
API 版本迁移案例:v1 → v2
展示从 v1 到 v2 的完整 API 版本迁移过程,包括变更分析、兼容策略、OpenAPI 差异。
背景
订单服务 API v1 已运行 1 年。产品团队要求新增以下功能: 1. 支持多币种(原来只有人民币) 2. 订单项需要拆分展示 3. 增加分页标准化
这些变更涉及响应体字段变更,不兼容 v1,因此需要 v2。
变更分析
| 变更项 | 类型 | v1 | v2 |
|---|---|---|---|
| ID 字段名 | 重命名 | id (int) | orderId (string) |
| 金额格式 | 类型变更 | amount (number) | totalAmount (string) + currency |
| 订单项 | 新增 | 无独立列表 | items array |
| 响应封装 | 结构变更 | 裸数据 | Result<T> 包装 |
| 分页 | 标准化 | 无标准分页 | page/size/total/records |
v1 端点
v1 OpenAPI
openapi: 3.0.3
info:
title: Order Service API
version: 1.0.0
servers:
- url: https://api.example.com/api/v1
paths:
/orders:
get:
summary: List orders
parameters:
- name: status
in: query
schema:
type: string
responses:
'200':
description: Order list
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OrderV1'
post:
summary: Create order
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequestV1'
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/OrderV1'
components:
schemas:
OrderV1:
type: object
properties:
id:
type: integer
example: 1
status:
type: string
example: PAID
amount:
type: number
example: 99.00
CreateOrderRequestV1:
type: object
properties:
customer_id:
type: integer
items:
type: array
items:
type: object
properties:
product_id:
type: integer
quantity:
type: integerv1 返回示例
// GET /api/v1/orders?status=PAID
[
{
"id": 1,
"status": "PAID",
"amount": 99.00
},
{
"id": 2,
"status": "PAID",
"amount": 199.00
}
]
// POST /api/v1/orders
{
"id": 3,
"status": "DRAFT",
"amount": 299.00
}v2 端点
v2 OpenAPI
openapi: 3.0.3
info:
title: Order Service API
version: 2.0.0
servers:
- url: https://api.example.com/api/v2
paths:
/orders:
get:
summary: List orders with pagination
parameters:
- name: status
in: query
schema:
type: string
enum: [DRAFT, PAID, SHIPPED, CANCELLED]
- name: page
in: query
schema:
type: integer
default: 1
- name: size
in: query
schema:
type: integer
default: 20
responses:
'200':
description: Paginated order list
content:
application/json:
schema:
$ref: '#/components/schemas/OrderListResponseV2'
post:
summary: Create order
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequestV2'
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
components:
schemas:
OrderV2:
type: object
properties:
orderId:
type: string
example: ORD-2024-001
status:
type: string
example: PAID
totalAmount:
type: string
example: "99.00"
currency:
type: string
example: CNY
items:
type: array
items:
$ref: '#/components/schemas/OrderItemV2'
createdAt:
type: string
format: date-time
OrderItemV2:
type: object
properties:
productName:
type: string
quantity:
type: integer
unitPrice:
type: string
subtotal:
type: string
OrderListResponseV2:
type: object
properties:
code:
type: integer
example: 0
message:
type: string
data:
type: object
properties:
records:
type: array
items:
$ref: '#/components/schemas/OrderSummaryV2'
total:
type: integer
example: 100
page:
type: integer
example: 1
pageSize:
type: integer
example: 20
OrderSummaryV2:
type: object
properties:
orderId:
type: string
status:
type: string
totalAmount:
type: string
itemCount:
type: integer
createdAt:
type: string
format: date-time
ApiResponse:
type: object
properties:
code:
type: integer
example: 0
message:
type: string
example: success
data:
type: object
CreateOrderRequestV2:
type: object
required: [customerId, items, currency]
properties:
customerId:
type: string
currency:
type: string
enum: [CNY, USD, EUR]
items:
type: array
items:
type: object
properties:
productId:
type: string
quantity:
type: integerv2 返回示例
// GET /api/v2/orders?status=PAID&page=1
{
"code": 0,
"message": "success",
"data": {
"records": [
{
"orderId": "ORD-2024-001",
"status": "PAID",
"totalAmount": "99.00",
"itemCount": 2,
"currency": "CNY",
"createdAt": "2024-01-15T10:30:00Z"
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1
}
}
// POST /api/v2/orders
{
"code": 0,
"message": "success",
"data": {
"orderId": "ORD-2024-003",
"status": "DRAFT",
"createdAt": "2024-01-16T14:00:00Z"
}
}兼容策略:双运行期
路由方案
API Gateway:
/api/v1/* → Order Service v1 (old deployment)
/api/v2/* → Order Service v2 (new deployment)
Internal routing:
v1 service → v1 DB schema
v2 service → v2 DB schema (migrated)适配层(v1 → v2 数据转换)
当 v1 用户调用 v2 端点时,通过适配器转换:
@Component
public class OrderV1ToV2Adapter {
public OrderListResponseV2 adapt(List<OrderV1> v1Orders) {
List<OrderSummaryV2> records = v1Orders.stream()
.map(v1 -> new OrderSummaryV2(
"ORD-" + v1.getId(), // id → orderId
v1.getStatus(),
String.format("%.2f", v1.getAmount()), // number → string
0, // itemCount (v1 didn't have it)
null // createdAt (v1 didn't have timestamp)
))
.collect(toList());
return new OrderListResponseV2(
0, "success",
new PaginatedData<>(records, records.size(), 1, records.size(), 1)
);
}
}迁移时间线
2024-Q1: v2 设计 + 开发
2024-Q2: v2 上线,v1 + v2 双运行
└── 通知所有 v1 客户端开始迁移
2024-Q3: v1 废弃期
└── v1 响应添加 Deprecated header
└── v1 流量监控
2024-Q4: v1 下线
└── v1 端点返回 410 Gone
└── 删除 v1 代码和部署客户端迁移指南
# 迁移到 Order API v2
## 关键变更
1. `id` → `orderId`(int → string)
2. 响应改为 Result<T> 包装
3. 分页标准化为 records/page/size/total
4. 金额改为字符串格式 "99.00" + 新增 currency 字段
## 迁移步骤
### Step 1:更新请求- POST /api/v1/orders
- { "customer_id": 1, "items": [...] }
+ POST /api/v2/orders + { "customerId": "USR-001", "currency": "CNY", "items": [...] }
### Step 2:更新响应解析- const orderId = response.id;
+ const orderId = response.data.orderId;
### Step 3:更新分页处理- const total = response.length;
+ const total = response.data.total; + const page = response.data.page;
## 回退方案
如遇兼容性问题,切换回 /api/v1/ 端点并报告问题。
v1 将在 2024-Q4 下线。库存服务 API 设计案例
库存限界上下文(Inventory BC)的 REST API 设计,展示库存扣减、锁定、释放等 CQRS 场景。
领域模型
Inventory (Aggregate Root)
├── SkuId
├── AvailableQty
├── LockedQty
└── WarehouseCode端点设计
| 端点 | 类型 | 说明 |
|---|---|---|
POST /api/v1/inventory/lock | Command | 锁定库存(下单) |
POST /api/v1/inventory/release | Command | 释放库存(取消) |
POST /api/v1/inventory/deduct | Command | 扣减库存(支付) |
GET /api/v1/inventory/{skuId} | Query | 库存查询 |
GET /api/v1/inventory/warehouse/{code} | Query | 仓库库存概览 |
幂等设计
锁定/扣减操作使用 Idempotency-Key 防止重复处理,服务端缓存 TTL 24h。
通知服务 API 设计案例
通知限界上下文(Notification BC)的 REST API 设计,展示消息发送、模板管理等场景。
领域模型
Notification (Entity)
├── NotificationId
├── UserId
├── Channel: EMAIL / SMS / PUSH
├── TemplateId
├── Status: PENDING / SENT / FAILED
└── SentAt端点设计
| 端点 | 类型 | 说明 |
|---|---|---|
POST /api/v1/notifications/send | Command | 发送通知 |
POST /api/v1/notifications/batch | Command | 批量发送 |
GET /api/v1/notifications/{id} | Query | 通知详情 |
GET /api/v1/notifications?userId=&status= | Query | 用户通知列表 |
POST /api/v1/notification-templates | Command | 创建模板 |
GET /api/v1/notification-templates | Query | 模板列表 |
OpenAPI 代码生成案例
展示 Spec-first 策略:从 OpenAPI 规范生成服务端骨架和客户端 SDK。
策略选择
| 策略 | 适用 | 工作流 |
|---|---|---|
| Code-first | 内部服务、快速迭代 | 注解 → 运行时导出 Spec → 生成客户端 |
| Spec-first | 公共 API、团队协作 | 先写 YAML → 生成服务端骨架 → 补齐业务逻辑 |
| Hybrid | 企业级、多消费者 | 注解 + 规范审查 → 导出 → 生成 SDK |
推荐:内部 DDD 服务用 Code-first(SpringDoc),公共 API 用 Spec-first(openapi-generator)。
场景:支付服务 API 规范(Spec-first)
1. 编写 OpenAPI 规范
# payment-api-v1.yaml
openapi: 3.0.3
info:
title: Payment Service API
version: 1.0.0
description: 支付服务 API — 支付发起、回调、退款
servers:
- url: https://api.example.com/api/v1
paths:
/payments:
post:
operationId: initiatePayment
tags: [Payment Commands]
parameters:
- name: Idempotency-Key
in: header
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/InitiatePaymentRequest'
responses:
'201':
description: 支付发起成功
content:
application/json:
schema:
$ref: '#/components/schemas/PaymentCreatedResponse'
'400':
$ref: '#/components/responses/BusinessError'
x-springdoc-default: "40001"
/payments/{paymentId}:
get:
operationId: getPayment
tags: [Payment Queries]
parameters:
- name: paymentId
in: path
required: true
schema:
type: string
pattern: '^PAY-\d{4}-\d{3,6}$'
responses:
'200':
description: 支付详情
content:
application/json:
schema:
$ref: '#/components/schemas/PaymentDetailResponse'
components:
schemas:
InitiatePaymentRequest:
type: object
required: [orderId, amount, currency, method]
properties:
orderId:
type: string
example: ORD-2024-001
amount:
type: string
example: "99.00"
currency:
type: string
enum: [CNY, USD]
method:
type: string
enum: [WECHAT_PAY, ALIPAY, CARD]
PaymentCreatedData:
type: object
properties:
paymentId:
type: string
status:
type: string
enum: [PROCESSING]
payUrl:
type: string
expiresIn:
type: integer
PaymentCreatedResponse:
type: object
properties:
code:
type: integer
example: 0
message:
type: string
data:
$ref: '#/components/schemas/PaymentCreatedData'
PaymentDetailData:
type: object
properties:
paymentId:
type: string
orderId:
type: string
status:
type: string
amount:
type: string
method:
type: string
channelOrderNo:
type: string
createdAt:
type: string
format: date-time
PaymentDetailResponse:
type: object
properties:
code:
type: integer
message:
type: string
data:
$ref: '#/components/schemas/PaymentDetailData'
ApiError:
type: object
properties:
code:
type: integer
message:
type: string
detail:
type: string
requestId:
type: string
responses:
BusinessError:
description: Business error
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'2. 生成服务端骨架
# 使用 openapi-generator 生成 Java Spring 服务端
openapi-generator generate \
-i payment-api-v1.yaml \
-g spring \
-o payment-service \
--api-package com.example.payment.adapter.inbound.web \
--model-package com.example.payment.adapter.inbound.dto \
--additional-properties=interfaceOnly=true
# 生成目录结构
payment-service/
├── src/main/java/com/example/payment/adapter/inbound/
│ ├── web/
│ │ ├── PaymentApi.java # 生成的接口
│ │ └── PaymentApiController.java # 生成的骨架
│ └── dto/
│ ├── InitiatePaymentRequest.java # 生成的请求 DTO
│ ├── PaymentCreatedResponse.java # 生成的响应 DTO
│ └── ApiError.java # 生成的错误 DTO3. 实现业务逻辑
@RestController
public class PaymentController implements PaymentApi {
private final PaymentApplicationService paymentService;
@Override
public ResponseEntity<PaymentCreatedResponse> initiatePayment(
String idempotencyKey,
InitiatePaymentRequest request) {
// 1. 请求 DTO → 命令对象
InitiatePaymentCommand command = new InitiatePaymentCommand(
OrderId.of(request.getOrderId()),
Money.of(request.getAmount(), Currency.valueOf(request.getCurrency())),
PaymentMethod.valueOf(request.getMethod())
);
// 2. 调用应用服务
PaymentResult result = paymentService.initiate(command, idempotencyKey);
// 3. 领域结果 → 响应 DTO
PaymentCreatedData data = new PaymentCreatedData();
data.setPaymentId(result.getPaymentId());
data.setStatus("PROCESSING");
data.setPayUrl(result.getPayUrl());
data.setExpiresIn(300);
PaymentCreatedResponse response = new PaymentCreatedResponse();
response.setCode(0);
response.setMessage("success");
response.setData(data);
return ResponseEntity.status(201).body(response);
}
}4. 生成客户端 SDK
# 生成 TypeScript 客户端
openapi-generator generate \
-i payment-api-v1.yaml \
-g typescript-axios \
-o payment-client-ts
# 生成 Java 客户端
openapi-generator generate \
-i payment-api-v1.yaml \
-g java \
-o payment-client-java5. 客户端使用
// TypeScript 客户端
import { PaymentApi } from './payment-client-ts';
const api = new PaymentApi();
// 发起支付
const response = await api.initiatePayment(
{ orderId: "ORD-2024-001", amount: "99.00", currency: "CNY", method: "WECHAT_PAY" },
{ headers: { "Idempotency-Key": uuidv4() } }
);
console.log(response.data.data.payUrl); // weixin://pay/...Code-first 方案(SpringDoc 注解)
@RestController
@RequestMapping("/api/v1/payments")
@Tag(name = "Payment Commands", description = "支付命令端点")
public class PaymentController {
@Operation(summary = "发起支付", operationId = "initiatePayment")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "支付发起成功"),
@ApiResponse(responseCode = "400", description = "业务错误",
content = @Content(schema = @Schema(implementation = ApiError.class)))
})
@PostMapping
public ResponseEntity<PaymentCreatedResponse> initiatePayment(
@RequestHeader("Idempotency-Key") @Parameter(description = "幂等键")
String idempotencyKey,
@RequestBody @Valid InitiatePaymentRequest request) {
// ...
}
}导出 Spec:
# application.yml
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
packages-to-scan: com.example.payment.adapter.inbound.web# 运行期导出 OpenAPI 规范
curl http://localhost:8080/api-docs > payment-api-spec.yamlOpenAPI 代码生成最佳实践
1. 每个 BC 一个 Spec 文件:Order BC 和 Payment BC 的 Spec 独立管理 2. 用 operationId 对应方法名:便于代码生成的方法映射 3. 接口优先(interfaceOnly=true):生成接口,不生成实现,避免覆盖业务代码 4. DTO 和 VO 不走代码生成:DDD 项目中的 DTO/VO 应手工设计,annotations 不适合复杂 DTO 结构 5. Spec 纳入版本控制:每次 API 变更必须更新 Spec 文件 6. Spec diff 作为 Code Review 一部分:审查 Spec 变更后再合入 7. 客户端 SDK 按需生成:不要将生成的 SDK 提交到仓库,通过 CI 按需构建
订单服务完整 API 设计案例
基于 DDD 订单聚合,完整展示 CQRS 分离、数据对象转换链、BFF 适配。
领域模型
Order (Aggregate Root)
├── OrderId (ValueObject)
├── OrderStatus: DRAFT → PAID → SHIPPED → DELIVERED
│ ↘ CANCELLED
├── CustomerId (ValueObject) — 引用客户聚合
├── List<OrderItem> (Entity)
│ ├── OrderItemId
│ ├── ProductId — 引用商品聚合
│ ├── Quantity
│ └── UnitPrice
├── Money totalAmount (ValueObject)
└── List<DomainEvent>1. CQRS 端点设计
命令端点(写)
POST /api/v1/orders → 创建订单
PUT /api/v1/orders/{orderId}/confirm → 确认订单
PUT /api/v1/orders/{orderId}/ship → 标记发货
PUT /api/v1/orders/{orderId}/deliver → 标记送达
DELETE /api/v1/orders/{orderId} → 取消订单查询端点(读)
GET /api/v1/orders/{orderId} → 订单详情
GET /api/v1/orders?status=PAID&page=1 → 订单列表
GET /api/v1/orders/{orderId}/items → 订单项列表2. 数据对象转换链
PO (OrderPO) ↔ DO (Order) ↔ DTO (OrderDTO) ↔ VO (OrderDetailVO / OrderSummaryVO)PO — 基础设施层
@Entity
@Table(name = "orders")
public class OrderPO {
@Id
private Long id;
private String orderNo;
private String status; // 数据库存字符串
private Long customerId;
private BigDecimal totalAmount;
private String currency;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Integer version; // 乐观锁
}DO — 领域层
public class Order extends AggregateRoot<OrderId> {
private OrderId id;
private OrderStatus status;
private CustomerId customerId;
private Money totalAmount;
private List<OrderItem> items;
public Order(OrderId id, CustomerId customerId, List<OrderItem> items) {
this.id = id;
this.customerId = customerId;
this.items = Collections.unmodifiableList(items);
this.status = OrderStatus.DRAFT;
this.totalAmount = calculateTotal();
addDomainEvent(new OrderCreatedEvent(id, customerId, totalAmount));
}
public void confirm() {
if (!status.canConfirm()) {
throw new BusinessException(40001, "订单状态不允许确认",
"当前状态:" + status + ",可确认状态:DRAFT");
}
this.status = OrderStatus.CONFIRMED;
addDomainEvent(new OrderConfirmedEvent(id));
}
private Money calculateTotal() {
return items.stream()
.map(OrderItem::getSubtotal)
.reduce(Money.ZERO, Money::add);
}
}DTO — 接口层
// 命令 DTO
public record CreateOrderRequest(
@NotNull String customerId,
@NotEmpty List<@Valid OrderItemRequest> items
) {
public CreateOrderCommand toCommand() {
return new CreateOrderCommand(
CustomerId.of(this.customerId()),
this.items().stream().map(OrderItemRequest::toItem).toList()
);
}
}
// 查询 DTO
public record OrderDetailDTO(
String orderId,
String status,
String totalAmount,
List<OrderItemDTO> items,
String createdAt
) {}VO — 前端视图
{
"orderId": "ORD-2024-001",
"status": "PAID",
"statusText": "已支付",
"totalAmount": "¥99.00",
"items": [
{ "productName": "T-Shirt", "quantity": 2, "price": "¥49.50" }
],
"actions": ["cancel", "apply_return"]
}3. 转换器(Assembler)
public class OrderAssembler {
// DO → DTO (read direction)
public static OrderDetailDTO toDetailDTO(Order order) {
return new OrderDetailDTO(
order.getId().getValue(),
order.getStatus().name(),
order.getTotalAmount().toString(),
order.getItems().stream().map(OrderAssembler::toItemDTO).toList(),
order.getCreatedAt().toString()
);
}
public static OrderSummaryDTO toSummaryDTO(Order order) {
return new OrderSummaryDTO(
order.getId().getValue(),
order.getStatus().name(),
order.getTotalAmount().toString(),
order.getItems().size()
);
}
// Command → DO (write direction)
public static Order toDomain(CreateOrderCommand command) {
List<OrderItem> items = command.items().stream()
.map(item -> new OrderItem(
OrderItemId.generate(),
ProductId.of(item.productId()),
item.quantity(),
item.unitPrice()
)).toList();
return new Order(OrderId.generate(), command.customerId(), items);
}
}4. 统一响应
// 创建订单成功
POST /api/v1/orders → 201
{
"code": 0,
"message": "success",
"data": {
"orderId": "ORD-2024-001",
"status": "DRAFT",
"createdAt": "2024-01-15T10:30:00Z"
}
}
// 订单列表查询
GET /api/v1/orders?status=PAID&page=1 → 200
{
"code": 0,
"message": "success",
"data": {
"records": [
{ "orderId": "ORD-2024-001", "status": "PAID", "totalAmount": "99.00", "itemCount": 2 }
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1
}
}5. OpenAPI 摘要
openapi: 3.0.3
info:
title: Order Service API
version: 1.0.0
servers:
- url: https://api.example.com/api/v1
paths:
/orders:
post:
tags: [Order Commands]
summary: Create order
requestBody:
$ref: '#/components/schemas/CreateOrderRequest'
responses:
'201': { $ref: '#/components/schemas/OrderCreatedResponse' }
get:
tags: [Order Queries]
summary: List orders
parameters:
- name: status
in: query
schema: { type: string }
responses:
'200': { $ref: '#/components/schemas/OrderListResponse' }支付服务完整 API 设计案例
支付限界上下文(Payment BC)的 REST API 设计,展示幂等、状态机、异步回调等典型场景。
领域模型
Payment (Aggregate Root)
├── PaymentId (ValueObject)
├── OrderId (ValueObject) — 引用订单聚合
├── PaymentStatus: UNPAID → PROCESSING → SUCCESS → REFUNDING → REFUNDED
│ ↘ FAILED
├── Money amount (ValueObject)
├── PaymentMethod (ValueObject): WECHAT_PAY / ALIPAY / CARD
├── PaymentChannel (Entity):通道请求记录
│ ├── channelType
│ ├── channelOrderNo
│ └── channelStatus
└── List<PaymentEvent> (DomainEvent)1. CQRS 端点设计
命令端点(写)
POST /api/v1/payments → 发起支付
POST /api/v1/payments/{paymentId}/refund → 发起退款
PUT /api/v1/payments/{paymentId}/cancel → 取消支付
# 回调端点(第三方支付异步通知)
POST /api/v1/payments/callback/wechat → 微信支付回调
POST /api/v1/payments/callback/alipay → 支付宝回调查询端点(读)
GET /api/v1/payments/{paymentId} → 支付详情
GET /api/v1/payments/order/{orderId} → 订单支付记录列表2. 幂等设计
支付 API 的幂等设计是核心:防止重复扣款。
幂等键应用
# 发起支付 — 幂等键防止重复支付请求
POST /api/v1/payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
private final IdempotencyService idempotencyService;
public PaymentResult initiatePayment(CreatePaymentCommand command, String idempotencyKey) {
// 1. 幂等检查
PaymentResult cached = idempotencyService.getCachedResult(idempotencyKey);
if (cached != null) return cached;
// 2. 业务幂等:同一订单不能重复发起支付
paymentRepository.findByOrderId(command.orderId())
.filter(p -> p.getStatus() != PaymentStatus.UNPAID)
.ifPresent(p -> { throw new BusinessException(40001, "该订单已发起支付"); });
// 3. 执行支付
Payment payment = Payment.create(command);
PaymentResult result = paymentRepository.save(payment);
// 4. 缓存幂等结果
idempotencyService.cacheResult(idempotencyKey, result);
return result;
}
}退款幂等
public class Payment {
public RefundRecord refund(Money amount, String reason) {
// 状态机幂等:已退款状态不可重复退款
if (this.status == PaymentStatus.REFUNDED) {
throw new BusinessException(40001, "该支付已全额退款");
}
// 部分退款:计算可退余额
Money refundable = this.amount.subtract(this.totalRefunded());
if (amount.greaterThan(refundable)) {
throw new BusinessException(40001, "可退余额不足",
"可退: " + refundable + ",申请: " + amount);
}
this.totalRefundedAmount = this.totalRefundedAmount.add(amount);
if (this.totalRefundedAmount.equals(this.amount)) {
this.status = PaymentStatus.REFUNDED;
} else {
this.status = PaymentStatus.REFUNDING;
}
addDomainEvent(new PaymentRefundedEvent(this.id, amount));
return new RefundRecord(RefundId.generate(), amount, reason, LocalDateTime.now());
}
}3. 异步回调处理
第三方支付异步通知(Webhook)的处理规范。
回调端点设计
@RestController
public class PaymentCallbackController {
private final PaymentCallbackHandler callbackHandler;
// 微信支付回调 — 统一入口
@PostMapping("/api/v1/payments/callback/wechat")
public ResponseEntity<String> handleWechatCallback(@RequestBody String xmlBody,
@RequestHeader Map<String, String> headers) {
// 1. 验签
if (!WechatSignature.verify(xmlBody, headers)) {
return ResponseEntity.status(401).body("signature verification failed");
}
// 2. 幂等处理(防止重复回调)
WechatNotify notify = WechatNotify.parse(xmlBody);
return callbackHandler.handleCallback("WECHAT", notify.getOutTradeNo(), () -> {
Payment payment = paymentRepository.findByOrderNo(notify.getOutTradeNo())
.orElseThrow(() -> new ResourceNotFoundException("Payment not found"));
if (notify.isSuccess()) {
payment.markSuccess(notify.getTransactionId(), notify.getPaidAt());
} else {
payment.markFailed(notify.getErrorCode());
}
paymentRepository.save(payment);
});
}
// 返回 "SUCCESS" 通知微信停止回调
// 返回其他 → 微信会重试(最多 3 天)
}回调幂等 Handler
@Service
public class PaymentCallbackHandler {
public ResponseEntity<String> handleCallback(String channel, String outTradeNo,
Runnable businessLogic) {
String lockKey = "callback:" + channel + ":" + outTradeNo;
// 分布式锁防并发
if (!redisLock.tryLock(lockKey, 30, TimeUnit.SECONDS)) {
return ResponseEntity.ok("SUCCESS"); // 另一个线程在处理
}
try {
// 幂等:已处理过的回调不再执行
if (redis.hasKey("callback:processed:" + outTradeNo)) {
return ResponseEntity.ok("SUCCESS");
}
businessLogic.run();
redis.set("callback:processed:" + outTradeNo, "1", 7, TimeUnit.DAYS);
return ResponseEntity.ok("SUCCESS");
} catch (Exception e) {
log.error("Callback processing failed", e);
return ResponseEntity.status(500).body("retry");
} finally {
redisLock.unlock(lockKey);
}
}
}4. 数据对象转换链
PaymentPO ↔ Payment (DO) ↔ PaymentDTO ↔ PaymentVODO — 充血模型
public class Payment extends AggregateRoot<PaymentId> {
private PaymentId id;
private OrderId orderId;
private PaymentStatus status;
private Money amount;
private PaymentMethod method;
private String channelOrderNo; // 第三方支付单号
private Money totalRefundedAmount;
// 创建支付
public static Payment create(OrderId orderId, Money amount, PaymentMethod method) {
Payment payment = new Payment(PaymentId.generate(), orderId, amount, method);
payment.status = PaymentStatus.UNPAID;
payment.addDomainEvent(new PaymentInitiatedEvent(payment.id, orderId, amount));
return payment;
}
// 支付成功回调
public void markSuccess(String channelOrderNo, LocalDateTime paidAt) {
if (this.status != PaymentStatus.PROCESSING) {
// 幂等:已成功的回调直接忽略
if (this.status == PaymentStatus.SUCCESS) return;
throw new BusinessException(40001, "当前状态不允许标记成功",
"当前:" + this.status);
}
this.status = PaymentStatus.SUCCESS;
this.channelOrderNo = channelOrderNo;
addDomainEvent(new PaymentSuccessEvent(this.id, this.orderId, this.amount, paidAt));
}
// 退款
public void refund(Money amount, String reason) {
if (this.status != PaymentStatus.SUCCESS) {
throw new BusinessException(40001, "只有已支付的订单才能退款");
}
Money refundable = this.amount.subtract(this.totalRefundedAmount);
if (amount.greaterThan(refundable)) {
throw new BusinessException(40001, "可退余额不足");
}
this.totalRefundedAmount = this.totalRefundedAmount.add(amount);
this.status = amount.equals(this.amount) ? PaymentStatus.REFUNDED : PaymentStatus.REFUNDING;
addDomainEvent(new PaymentRefundedEvent(this.id, this.orderId, amount));
}
}5. 统一响应
// 发起支付成功
POST /api/v1/payments → 201
{
"code": 0,
"message": "success",
"data": {
"paymentId": "PAY-2024-001",
"orderId": "ORD-2024-001",
"amount": "99.00",
"method": "WECHAT_PAY",
"status": "PROCESSING",
"payUrl": "weixin://pay/...",
"expiresIn": 300
}
}
// 微信回调处理成功
POST /api/v1/payments/callback/wechat → 200
HTTP body: "SUCCESS"
// 退款成功
POST /api/v1/payments/PAY-2024-001/refund → 200
{
"code": 0,
"message": "success",
"data": {
"refundId": "REF-2024-001",
"amount": "99.00",
"status": "REFUNDED",
"refundedAt": "2024-01-16T11:00:00Z"
}
}6. 安全设计
| 端点 | 认证 | 限流 | 幂等 | 特殊 |
|---|---|---|---|---|
| POST /payments | JWT + BC 授权 | 30/min/user | ✅ Idempotency-Key | 资源所有权检查 |
| POST /payments/{id}/refund | JWT + OPERATOR 角色 | 10/min/user | ✅ 状态机 + 业务幂等 | 退款金额校验 |
| POST /callback/* | 签名验证(无 JWT) | 100/min/IP | ✅ 去重表 + 回调幂等 | 白名单 IP |
| GET /payments/{id} | JWT | 200/min/user | 天然幂等 | — |
商品服务 API 设计案例
商品限界上下文(Product BC)的 REST API 设计,展示商品管理、分类查询等场景。
领域模型
Product (Aggregate Root)
├── ProductId
├── Sku
├── Title / Description
├── Price / CostPrice
├── CategoryId
└── ProductStatus: DRAFT / ONLINE / OFFLINE / DELETED端点设计
| 端点 | 类型 | 说明 |
|---|---|---|
POST /api/v1/products | Command | 创建商品 |
PUT /api/v1/products/{id} | Command | 更新商品 |
PUT /api/v1/products/{id}/online | Command | 上架 |
PUT /api/v1/products/{id}/offline | Command | 下架 |
GET /api/v1/products/{id} | Query | 商品详情 DTO |
GET /api/v1/products?category=&page=&size= | Query | 商品列表(分页) |
DTO 设计
ProductDetailDTO:详情页(含描述、规格)ProductSummaryDTO:列表页(标题、价格、封面图)ProductCreateCommand:创建请求
搜索服务 API 设计案例
搜索上下文(Search BC)的 REST API 设计,展示 CQRS 查询侧的物化视图设计。
领域模型
SearchIndex (Read Model)
├── DocumentId
├── DocumentType
├── Title / Content
├── Tags[]
└── IndexedAt端点设计
| 端点 | 类型 | 说明 |
|---|---|---|
GET /api/v1/search?q=&type=&page= | Query | 全文搜索 |
POST /api/v1/search/index | Command | 重建索引 |
GET /api/v1/search/suggest?q= | Query | 搜索建议 |
GET /api/v1/search/facets?field= | Query | 聚合统计 |
查询侧设计要点
- 搜索结果使用 Cursor 分页(
?cursor=xxx&size=20) - 支持字段选择(
?fields=id,title,summary) - 结果按相关性排序(评分倒序)
用户服务完整 API 设计案例
用户限界上下文(User BC)的 REST API 设计,展示注册、登录、资料管理等典型 CQRS 场景。
领域模型
User (Aggregate Root)
├── UserId (ValueObject)
├── Email (ValueObject)
├── PasswordHash (ValueObject)
├── UserProfile (ValueObject)
│ ├── Nickname
│ ├── Avatar
│ └── Phone
└── UserStatus: ACTIVE / SUSPENDED / DELETED1. CQRS 端点设计
命令端点(写)
POST /api/v1/users/register → 用户注册
POST /api/v1/users/login → 用户登录
PUT /api/v1/users/profile → 更新资料
PUT /api/v1/users/password → 修改密码
DELETE /api/v1/users/{userId} → 注销账号
POST /api/v1/users/password/reset → 重置密码(发送邮件)查询端点(读)
GET /api/v1/users/me → 当前用户信息
GET /api/v1/users/{userId} → 指定用户信息(公开)2. 数据对象转换链
UserPO ↔ User(UserDO) ↔ UserDTO ↔ UserProfileVOPO
@Entity
@Table(name = "users")
public class UserPO {
@Id
private Long id;
private String email;
private String passwordHash;
private String nickname;
private String avatar;
private String phone;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}DO
public class User extends AggregateRoot<UserId> {
private UserId id;
private Email email;
private PasswordHash passwordHash;
private UserProfile profile;
private UserStatus status;
public static User register(Email email, PasswordHash passwordHash) {
User user = new User(UserId.generate(), email, passwordHash);
user.addDomainEvent(new UserRegisteredEvent(user.id, user.email));
return user;
}
public void updateProfile(UserProfile newProfile) {
this.profile = newProfile;
addDomainEvent(new UserProfileUpdatedEvent(this.id));
}
public void changePassword(PasswordHash oldPwd, PasswordHash newPwd) {
if (!this.passwordHash.matches(oldPwd)) {
throw new BusinessException(40101, "原密码不正确");
}
this.passwordHash = newPwd;
addDomainEvent(new UserPasswordChangedEvent(this.id));
}
}DTO
// 注册命令 DTO
public record RegisterRequest(
@Email String email,
@NotBlank @Size(min = 6, max = 32) String password,
@NotBlank String nickname
) {
public RegisterCommand toCommand() {
return new RegisterCommand(Email.of(email), PasswordHash.encode(password), nickname);
}
}
// 登录命令 DTO
public record LoginRequest(
@Email String email,
@NotBlank String password
) {}
// 查询 DTO
public record UserDetailDTO(
String userId,
String email,
String nickname,
String avatar,
String status,
String createdAt
) {}VO(前端视图)
{
"userId": "USR-2024-001",
"nickname": "张三",
"avatar": "https://cdn.example.com/avatars/001.jpg",
"isVerified": true,
"memberSince": "2024-01-15",
"settings": {
"notifications": true,
"language": "zh-CN"
}
}3. 转换器
public class UserAssembler {
// DO → DTO
public static UserDetailDTO toDetailDTO(User user) {
return new UserDetailDTO(
user.getId().getValue(),
user.getEmail().getValue(),
user.getProfile().getNickname(),
user.getProfile().getAvatar(),
user.getStatus().name(),
user.getCreatedAt().toString()
);
}
// DO → 登录响应
public static LoginResponse toLoginResponse(User user, String token) {
return new LoginResponse(
token,
toDetailDTO(user)
);
}
}4. 统一响应
// 注册成功
POST /api/v1/users/register → 201
{
"code": 0,
"message": "success",
"data": {
"userId": "USR-2024-001",
"email": "zhang@example.com"
}
}
// 登录成功
POST /api/v1/users/login → 200
{
"code": 0,
"message": "success",
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 3600,
"user": {
"userId": "USR-2024-001",
"nickname": "张三",
"avatar": "https://cdn.example.com/avatars/001.jpg"
}
}
}
// 邮箱已注册
POST /api/v1/users/register → 409
{
"code": 40901,
"message": "该邮箱已被注册",
"requestId": "req-xyz789"
}5. 安全设计
| 端点 | 认证 | 限流 | 备注 |
|---|---|---|---|
| POST /register | 无 | 5/min/IP | 防恶意注册 |
| POST /login | 无 | 10/min/IP | 防暴力破解 |
| POST /password/reset | 无 | 3/min/IP | 防滥用 |
| PUT /profile | JWT | 30/min/user | — |
| GET /users/me | JWT | 100/min/user | — |
| DELETE /users/{userId} | JWT+资源所有权检查 | 5/min/user | — |
Hexagonal Architecture (Ports & Adapters)
Sources:
Primary:
- Hexagonal Architecture — Alistair Cockburn (2005)
- Hexagonal Architecture Explained — Alistair Cockburn & Juan Manuel Garrido de Paz (2024)
- Interview with Alistair Cockburn — Juan Manuel Garrido de Paz
Implementation guide:
- Hexagonal Architecture Pattern — AWS
Core Concept
"Allow an application to equally be driven by users, programs, automated tests, or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases."
— Alistair Cockburn
Design validation technique: The pattern was designed with FIT testing in mind—business experts can write test cases before any GUI exists. If you can run your entire application from test fixtures, your hexagonal boundaries are correct.
The hexagon is conceptual. Most applications have 2-4 ports, not six. The shape emphasizes that all external interactions go through ports, regardless of direction.
This file uses a Hexagonal-focused layout where driven ports live under application/ports/driven/. In a DDD-centered layout, aggregate repository interfaces often live beside the aggregate in domain/. The important rule is ownership: the application/domain defines the abstractions it needs, and technology adapters implement them from the outside.
flowchart TB
subgraph DriverSide["DRIVER SIDE (Primary / Inbound / Left)"]
REST["REST API Adapter"]
CLI["CLI Adapter"]
DriverPorts["DRIVER PORTS\n(Use Case Interfaces)"]
REST --> DriverPorts
CLI --> DriverPorts
end
subgraph Hexagon["THE HEXAGON"]
subgraph AppCore["APPLICATION CORE"]
subgraph Domain["DOMAIN\n(Business Logic)"]
BL[" "]
end
end
end
subgraph DrivenSide["DRIVEN SIDE (Secondary / Outbound / Right)"]
DrivenPorts["DRIVEN PORTS\n(Repository Interfaces)"]
Postgres["Postgres Adapter"]
RabbitMQ["RabbitMQ Adapter"]
DrivenPorts --> Postgres
DrivenPorts --> RabbitMQ
end
DriverPorts --> AppCore
AppCore --> DrivenPorts
style DriverSide fill:#3b82f6,stroke:#2563eb,color:white
style Hexagon fill:#10b981,stroke:#059669,color:white
style DrivenSide fill:#f59e0b,stroke:#d97706,color:white
style Domain fill:#059669,stroke:#047857,color:white---
Ports
Interfaces defining how the application communicates with the outside world.
Explicit port interfaces are useful when multiple adapters, testing seams, or team boundaries justify them. For small codebases, a public use-case handler method can be enough as the driver port.
Driver Ports (Primary / Inbound)
Define how the world uses your application.
- Entry points to the application
- Called by adapters
- Represent use cases
// application/ports/driver/place_order_port.ts
export interface IPlaceOrderPort {
execute(command: PlaceOrderCommand): Promise<OrderId>;
}
// application/ports/driver/get_order_port.ts
export interface IGetOrderPort {
execute(query: GetOrderQuery): Promise<OrderDTO | null>;
}
// application/ports/driver/cancel_order_port.ts
export interface ICancelOrderPort {
execute(command: CancelOrderCommand): Promise<void>;
}Driven Ports (Secondary / Outbound)
Define how your application uses external systems.
- Dependencies the application needs
- Implemented by adapters
- Application calls these interfaces
// application/ports/driven/order_repository_port.ts
export interface IOrderRepositoryPort {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
delete(order: Order): Promise<void>;
}
// application/ports/driven/event_publisher_port.ts
export interface IEventPublisherPort {
publish(event: DomainEvent): Promise<void>;
publishAll(events: DomainEvent[]): Promise<void>;
}
// application/ports/driven/payment_gateway_port.ts
export interface IPaymentGatewayPort {
charge(amount: Money, paymentMethod: PaymentMethod): Promise<PaymentResult>;
refund(paymentId: PaymentId, amount: Money): Promise<RefundResult>;
}
// application/ports/driven/notification_port.ts
export interface INotificationPort {
sendEmail(to: Email, template: EmailTemplate): Promise<void>;
sendSMS(to: PhoneNumber, message: string): Promise<void>;
}---
Adapters
Concrete implementations that connect ports to external technologies.
Driver Adapters (Primary / Inbound)
Convert external inputs to port calls.
// infrastructure/adapters/driver/rest/order_controller.ts
import { Router, Request, Response } from 'express';
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
import { IGetOrderPort } from '@/application/ports/driver/get_order_port';
export class OrderController {
constructor(
private readonly placeOrder: IPlaceOrderPort,
private readonly getOrder: IGetOrderPort,
) {}
async create(req: Request, res: Response): Promise<void> {
const command: PlaceOrderCommand = {
customerId: req.user.id,
items: req.body.items.map((item: any) => ({
productId: item.product_id,
quantity: item.quantity,
})),
};
const orderId = await this.placeOrder.execute(command);
res.status(201).json({ id: orderId.value });
}
async show(req: Request, res: Response): Promise<void> {
const order = await this.getOrder.execute({ orderId: req.params.id });
if (!order) {
res.status(404).json({ error: 'Order not found' });
return;
}
res.json(order);
}
}
// infrastructure/adapters/driver/grpc/order_service.ts
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
import { OrderServiceServer, PlaceOrderRequest, PlaceOrderResponse } from './generated/order_pb';
export class GrpcOrderService implements OrderServiceServer {
constructor(private readonly placeOrder: IPlaceOrderPort) {}
async placeOrder(
request: PlaceOrderRequest,
): Promise<PlaceOrderResponse> {
const command: PlaceOrderCommand = {
customerId: request.getCustomerId(),
items: request.getItemsList().map(item => ({
productId: item.getProductId(),
quantity: item.getQuantity(),
})),
};
const orderId = await this.placeOrder.execute(command);
const response = new PlaceOrderResponse();
response.setOrderId(orderId.value);
return response;
}
}
// infrastructure/adapters/driver/cli/place_order_command.ts
import { Command } from 'commander';
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
export function createPlaceOrderCommand(placeOrder: IPlaceOrderPort): Command {
return new Command('place-order')
.description('Place a new order')
.requiredOption('-c, --customer <id>', 'Customer ID')
.requiredOption('-p, --product <id>', 'Product ID')
.requiredOption('-q, --quantity <number>', 'Quantity', parseInt)
.action(async (options) => {
const orderId = await placeOrder.execute({
customerId: options.customer,
items: [{ productId: options.product, quantity: options.quantity }],
});
console.log(`Order created: ${orderId.value}`);
});
}
// infrastructure/adapters/driver/message/order_message_handler.ts
import { IPlaceOrderPort } from '@/application/ports/driver/place_order_port';
export class OrderMessageHandler {
constructor(private readonly placeOrder: IPlaceOrderPort) {}
async handlePlaceOrderMessage(message: PlaceOrderMessage): Promise<void> {
await this.placeOrder.execute({
customerId: message.customerId,
items: message.items,
});
}
}Driven Adapters (Secondary / Outbound)
Implement port interfaces using specific technologies.
class PostgresOrderRepository implements IOrderRepositoryPort:
db: Database
findById(id: OrderId) -> Order | null:
row = db.orders.where(id: id.value).first()
if not row:
return null
return OrderMapper.toDomain(row)
save(order: Order):
data = OrderMapper.toPersistence(order)
db.orders.upsert(data)
delete(order: Order):
db.orders.where(id: order.id.value).delete()In-Memory (for tests):
class InMemoryOrderRepository implements IOrderRepositoryPort:
orders: Map<string, Order> = {}
findById(id: OrderId) -> Order | null:
return orders.get(id.value) or null
save(order: Order):
orders.set(order.id.value, order)
delete(order: Order):
orders.delete(order.id.value)
clear():
orders.clear()Payment Gateway:
class StripePaymentGateway implements IPaymentGatewayPort:
stripe: StripeClient
charge(amount: Money, paymentMethod: PaymentMethod) -> PaymentResult:
try:
intent = stripe.paymentIntents.create({
amount: amount.cents,
currency: amount.currency,
paymentMethod: paymentMethod.stripeId,
confirm: true
})
return PaymentResult.success(PaymentId.from(intent.id))
catch CardError as error:
return PaymentResult.failed(error.message)
refund(paymentId: PaymentId, amount: Money) -> RefundResult:
refund = stripe.refunds.create({paymentIntent: paymentId.value, amount: amount.cents})
return RefundResult.success(RefundId.from(refund.id))Event Publisher:
class RabbitMQEventPublisher implements IEventPublisherPort:
channel: Channel
publish(event: DomainEvent):
channel.publish("domain_events", event.eventType, serialize({
eventId: event.eventId,
eventType: event.eventType,
occurredAt: event.occurredAt,
payload: event.toPayload()
}))
publishAll(events: List<DomainEvent>):
for event in events:
publish(event)---
Naming Conventions
Alistair Cockburn's Recommended Pattern
Ports: For[Doing][Something]
- Driver:
ForPlacingOrders,ForConfiguringSettings - Driven:
ForStoringUsers,ForNotifyingAlerts
Adapters: Reference the technology
CliCommandForPlacingOrdersMysqlDatabaseForStoringUsersSlackNotifierForAlerts
Alternative Patterns
| Pattern | Port | Adapter |
|---|---|---|
| Interface/Impl | IOrderRepository | PostgresOrderRepository |
| Port suffix | OrderRepositoryPort | PostgresOrderAdapter |
| Using prefix | IOrderStorage | OrderStorageUsingPostgres |
Project Structure
Use this structure when you want all Hexagonal ports grouped by direction. If the codebase follows the DDD-centered default from SKILL.md, keep aggregate repositories in domain/{aggregate}/repository and reserve application/ports/driven/ for application-owned dependencies such as payment gateways, notification gateways, clocks, or event publishers.
src/
├── application/
│ ├── ports/
│ │ ├── driver/ # Inbound ports
│ │ │ ├── place_order_port.ts
│ │ │ ├── get_order_port.ts
│ │ │ └── cancel_order_port.ts
│ │ └── driven/ # Outbound ports
│ │ ├── order_repository_port.ts
│ │ ├── event_publisher_port.ts
│ │ └── payment_gateway_port.ts
│ └── use_cases/
│ ├── place_order/
│ │ └── handler.ts # Implements driver port
│ └── get_order/
│ └── handler.ts
├── infrastructure/
│ └── adapters/
│ ├── driver/ # Inbound adapters
│ │ ├── rest/
│ │ │ └── order_controller.ts
│ │ ├── grpc/
│ │ │ └── order_service.ts
│ │ └── cli/
│ │ └── commands.ts
│ └── driven/ # Outbound adapters
│ ├── postgres/
│ │ └── order_repository.ts
│ ├── rabbitmq/
│ │ └── event_publisher.ts
│ ├── stripe/
│ │ └── payment_gateway.ts
│ └── in_memory/ # Test adapters
│ ├── order_repository.ts
│ └── event_publisher.ts
└── domain/
└── ...---
Key Asymmetry
flowchart TB
subgraph Driver["DRIVER (Left)"]
direction TB
DA["Adapter\n(Controller)"]
DP["Port\n(Interface)"]
DA -->|calls| DP
end
subgraph Driven["DRIVEN (Right)"]
direction TB
DRP["Port\n(Interface)"]
DRA["Adapter\n(Postgres)"]
DRA -->|implements| DRP
end
Driver -.->|"Application defines\nwhat it OFFERS"| Note1[" "]
Driven -.->|"Application defines\nwhat it NEEDS"| Note2[" "]
style Driver fill:#3b82f6,stroke:#2563eb,color:white
style Driven fill:#f59e0b,stroke:#d97706,color:white
style Note1 fill:none,stroke:none
style Note2 fill:none,stroke:none---
Configurability via Adapters
The power of hexagonal architecture: swap adapters without changing the core.
// infrastructure/config/container.ts
function configureDevelopment(container: Container): void {
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
.to(InMemoryOrderRepository);
container.bind<IEventPublisherPort>('IEventPublisherPort')
.to(InMemoryEventPublisher);
container.bind<IPaymentGatewayPort>('IPaymentGatewayPort')
.to(FakePaymentGateway);
}
function configureTest(container: Container): void {
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
.to(InMemoryOrderRepository);
container.bind<IEventPublisherPort>('IEventPublisherPort')
.to(SpyEventPublisher);
container.bind<IPaymentGatewayPort>('IPaymentGatewayPort')
.to(MockPaymentGateway);
}
function configureProduction(container: Container): void {
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
.to(PostgresOrderRepository);
container.bind<IEventPublisherPort>('IEventPublisherPort')
.to(RabbitMQEventPublisher);
container.bind<IPaymentGatewayPort>('IPaymentGatewayPort')
.to(StripePaymentGateway);
}
function configureWithMongoDB(container: Container): void {
container.bind<IOrderRepositoryPort>('IOrderRepositoryPort')
.to(MongoDBOrderRepository);
}---
Strong vs Weak Hexagonal
Weak Implementation
Port is technology-aware (not truly abstract):
// ❌ Weak: Leaks SQL concepts
interface IOrderRepository {
findByQuery(sql: string, params: any[]): Promise<Order[]>;
}Strong Implementation
Port is fully technology-agnostic:
// ✅ Strong: Pure domain concepts
interface IOrderRepository {
findById(id: OrderId): Promise<Order | null>;
findByCustomer(customerId: CustomerId): Promise<Order[]>;
save(order: Order): Promise<void>;
}---
Benefits
1. Testability - Swap real adapters for test doubles 2. Flexibility - Change technologies without changing core 3. Independence - Develop core without external systems 4. Clear boundaries - Explicit interfaces between layers 5. Parallel development - Teams work on different adapters
在 DDD 分层架构和微服务代码模型里,我们根据领域对象的属性和依赖关系,将领域对象进行分层,定义了与之对应的代码对象和代码目录结构。分层架构确定了微服务的总体架构,微服务内的主要对象有服务和实体等,它们一起协作完成业务逻辑。
那在运行过程中,这些服务和实体在微服务各层是如何协作的呢?今天我们就来解剖一下基于 DDD 分层架构的微服务,看看它的内部结构到底是什么样的。
1. 服务的类型
我们先来回顾一下分层架构中的服务。按照分层架构设计出来的微服务,其内部有 Facade 服务、应用服务、领域服务和基础服务。各层服务的主要功能和职责如下。
Facade 服务:
位于用户接口层,包括接口和实现两部分。用于处理用户发送的 Restful 请求和解析用户输入的配置文件等,并将数据传递给应用层。或者在获取到应用层数据后,将 DO 组装成 DTO,将数据传输到前端应用。
位于应用层。用来表述应用和用户行为,负责服务的组合、编排和转发,负责处理业务用例的执行顺序以及结果拼装,对外提供粗粒度的服务。
位于领域层。领域服务封装核心的业务逻辑,实现需要多个实体协作的核心领域逻辑。它对多个实体或方法的业务逻辑进行组合或编排,或者在严格分层架构中对实体方法进行封装,以领域服务的方式供应用层调用。
位于基础层。提供基础资源服务(比如数据库、缓存等),实现各层的解耦,降低外部资源变化对业务应用逻辑的影响。基础服务主要为仓储服务,通过依赖倒置提供基础资源服务。领域服务和应用服务都可以调用仓储服务接口,通过仓储服务实现数据持久化。
2. 服务的调用
我们看一下下面这张图。微服务的服务调用包括三类主要场景:微服务内跨层服务调用,微服务之间服务调用和领域事件驱动。
微服务内跨层服务调用
微服务架构下往往采用前后端分离的设计模式,前端应用独立部署。前端应用调用发布在 API 网关上的 Facade 服务,Facade 定向到应用服务。应用服务作为服务组织和编排者,它的服务调用有这样两种路径:
第一种是应用服务调用并组装领域服务。此时领域服务会组装实体和实体方法,实现核心领域逻辑。领域服务通过仓储服务获取持久化数据对象,完成实体数据初始化。
第二种是应用服务直接调用仓储服务。这种方式主要针对像缓存、文件等类型的基础层数据访问。这类数据主要是查询操作,没有太多的领域逻辑,不经过领域层,不涉及数据库持久化对象。
微服务之间的服务调用
微服务之间的应用服务可以直接访问,也可以通过 API 网关访问。由于跨微服务操作,在进行数据新增和修改操作时,你需关注分布式事务,保证数据的一致性。
领域事件驱动
领域事件驱动包括微服务内和微服务之间的事件(详见
)。微服务内通过事件总线(EventBus)完成聚合之间的异步处理。微服务之间通过消息中间件完成。异步化的领域事件驱动机制是一种间接的服务访问方式。
当应用服务业务逻辑处理完成后,如果发生领域事件,可调用事件发布服务,完成事件发布。
当接收到订阅的主题数据时,事件订阅服务会调用事件处理领域服务,完成进一步的业务操作。
3. 服务的封装与组合
我们看一下下面这张图。微服务的服务是从领域层逐级向上封装、组合和暴露的。
基础层的服务形态主要是仓储服务。仓储服务包括接口和实现两部分。仓储接口服务供应用层或者领域层服务调用,仓储实现服务,完成领域对象的持久化或数据初始化。
领域层实现核心业务逻辑,负责表达领域模型业务概念、业务状态和业务规则。主要的服务形态有实体方法和领域服务。
实体采用充血模型,在实体类内部实现实体相关的所有业务逻辑,实现的形式是实体类中的方法。实体是微服务的原子业务逻辑单元。在设计时我们主要考虑实体自身的属性和业务行为,实现领域模型的核心基础能力。不必过多考虑外部操作和业务流程,这样才能保证领域模型的稳定性。
DDD 提倡富领域模型,尽量将业务逻辑归属到实体对象上,实在无法归属的部分则设计成领域服务。领域服务会对多个实体或实体方法进行组装和编排,实现跨多个实体的复杂核心业务逻辑。
对于严格分层架构,如果单个实体的方法需要对应用层暴露,则需要通过领域服务封装后才能暴露给应用服务。
应用层用来表述应用和用户行为,负责服务的组合、编排和转发,负责处理业务用例的执行顺序以及结果的拼装,负责不同聚合之间的服务和数据协调,负责微服务之间的事件发布和订阅。
通过应用服务对外暴露微服务的内部功能,这样就可以隐藏领域层核心业务逻辑的复杂性以及内部实现机制。应用层的主要服务形态有:应用服务、事件发布和订阅服务。
应用服务内用于组合和编排的服务,主要来源于领域服务,也可以是外部微服务的应用服务。除了完成服务的组合和编排外,应用服务内还可以完成安全认证、权限校验、初步的数据校验和分布式事务控制等功能。
为了实现微服务内聚合之间的解耦,聚合之间的服务调用和数据交互应通过应用服务来完成。原则上我们应该禁止聚合之间的领域服务直接调用和聚合之间的数据表关联。
用户接口层是前端应用和微服务之间服务访问和数据交换的桥梁。它处理前端发送的 Restful 请求和解析用户输入的配置文件等,将数据传递给应用层。或获取应用服务的数据后,进行数据组装,向前端提供数据服务。主要服务形态是 Facade 服务。
Facade 服务分为接口和实现两个部分。完成服务定向,DO 与 DTO 数据的转换和组装,实现前端与应用层数据的转换和交换。
4. 两种分层架构的服务依赖关系
现在我们回顾一下 DDD 分层架构,分层架构有一个重要的原则就是:每层只能与位于其下方的层发生耦合。
那根据耦合的紧密程度,分层架构可以分为两种:严格分层架构和松散分层架构。在严格分层架构中,任何层只能与位于其直接下方的层发生依赖。在松散分层架构中,任何层可以与其任意下方的层发生依赖。
下面我们来详细分析和比较一下这两种分层架构。
松散分层架构的服务依赖
我们看一下下面这张图,在松散分层架构中,领域层的实体方法和领域服务可以直接暴露给应用层和用户接口层。松散分层架构的服务依赖关系,无需逐级封装,可以快速暴露给上层。
但它存在一些问题,第一个是容易暴露领域层核心业务的实现逻辑;第二个是当实体方法或领域服务发生服务变更时,由于服务同时被多层服务调用和组合,不容易找出哪些上层服务调用和组合了它,不方便通知到所有的服务调用方。
我们再来看一张图,在松散分层架构中,实体 A 的方法在应用层组合后,暴露给用户接口层 aFacade。abDomainService 领域服务直接越过应用层,暴露给用户接口层 abFacade 服务。松散分层架构中任意下层服务都可以暴露给上层服务。
严格分层架构的服务依赖
我们看一下下面这张图,在严格分层架构中,每一层服务只能向紧邻的上一层提供服务。虽然实体、实体方法和领域服务都在领域层,但实体和实体方法只能暴露给领域服务,领域服务只能暴露给应用服务。
在严格分层架构中,服务如果需要跨层调用,下层服务需要在上层封装后,才可以提供跨层服务。比如实体方法需要向应用服务提供服务,它需要封装成领域服务。
这是因为通过封装你可以避免将核心业务逻辑的实现暴露给外部,将实体和方法封装成领域服务,也可以避免在应用层沉淀过多的本该属于领域层的核心业务逻辑,避免应用层变得臃肿。还有就是当服务发生变更时,由于服务只被紧邻上层的服务调用和组合,你只需要逐级告知紧邻上层就可以了,服务可管理性比松散分层架构要好是一定的。
我们还是看图,A 实体方法需封装成领域服务 aDomainService 才能暴露给应用服务 aAppService。abDomainService 领域服务组合和封装 A 和 B 实体的方法后,暴露给应用服务 abAppService。
数据对象视图
在 DDD 中有很多的数据对象,这些对象分布在不同的层里。它们在不同的阶段有不同的形态。你可以再回顾一下
,这一讲有详细的讲解。
我们先来看一下微服务内有哪些类型的数据对象?它们是如何协作和转换的?
数据持久化对象 PO(Persistent Object),与数据库结构一一映射,是数据持久化过程中的数据载体。
领域对象 DO(Domain Object),微服务运行时的实体,是核心业务的载体。
数据传输对象 DTO(Data Transfer Object),用于前端与应用层或者微服务之间的数据组装和传输,是应用之间数据传输的载体。
视图对象 VO(View Object),用于封装展示层指定页面或组件的数据。
我们结合下面这张图,看看微服务各层数据对象的职责和转换过程。
基础层的主要对象是 PO 对象。我们需要先建立 DO 和 PO 的映射关系。当 DO 数据需要持久化时,仓储服务会将 DO 转换为 PO 对象,完成数据库持久化操作。当 DO 数据需要初始化时,仓储服务从数据库获取数据形成 PO 对象,并将 PO 转换为 DO,完成数据初始化。
大多数情况下 PO 和 DO 是一一对应的。但也有 DO 和 PO 多对多的情况,在 DO 和 PO 数据转换时,需要进行数据重组。
领域层的主要对象是 DO 对象。DO 是实体和值对象的数据和业务行为载体,承载着基础的核心业务逻辑。通过 DO 和 PO 转换,我们可以完成数据持久化和初始化。
应用层的主要对象是 DO 对象。如果需要调用其它微服务的应用服务,DO 会转换为 DTO,完成跨微服务的数据组装和传输。用户接口层先完成 DTO 到 DO 的转换,然后应用服务接收 DO 进行业务处理。如果 DTO 与 DO 是一对多的关系,这时就需要进行 DO 数据重组。
用户接口层会完成 DO 和 DTO 的互转,完成微服务与前端应用数据交互及转换。Facade 服务会对多个 DO 对象进行组装,转换为 DTO 对象,向前端应用完成数据转换和传输。
前端应用主要是 VO 对象。展现层使用 VO 进行界面展示,通过用户接口层与应用层采用 DTO 对象进行数据交互。
今天我们分析了 DDD 分层架构下微服务的服务和数据的协作关系。为了实现聚合之间以及微服务各层之间的解耦,我们在每层定义了不同职责的服务和数据对象。在软件开发过程中,我们需要严格遵守各层服务和数据的职责要求,各据其位,各司其职。这样才能保证核心领域模型的稳定,同时也可以灵活应对外部需求的快速变化。
API 错误处理设计
全局异常处理机制:ControllerAdvice 捕获所有异常,按类型转换为统一错误响应。
- 参数校验异常 → 400 + 40002
- 业务异常 → 400 + 40001(业务码)
- 资源不存在 → 404 + 40401
- 权限不足 → 403 + 40301
- 系统异常 → 500 + 50000
DTO/VO/DO/PO 四层转换边界
对象定义与位置
| 对象 | 全称 | 层级 | 职责 |
|---|---|---|---|
| DO | Domain Object | Domain 层 | 充血模型,含业务行为 |
| DTO | Data Transfer Object | Application/Adapter | 跨层/跨服务传输 |
| VO | View Object | Adapter 层 | 面向页面展示 |
| PO | Persistent Object | Infrastructure | 数据库映射 |
转换链
Frontend VO ↔ Adapter DTO ↔ Application DO ↔ Infrastructure PO转换规则
DO → DTO
public class OrderAssembler {
public static OrderDTO toDTO(Order order) {
return OrderDTO.builder()
.orderId(order.getId().getValue())
.status(order.getStatus().name())
.total(order.getTotalAmount().toString())
.items(order.getItems().stream().map(OrderAssembler::toItemDTO).toList())
.build();
}
}PO → DO(仓储完成)
// Infrastructure 层
public class JpaOrderRepository implements OrderRepository {
public Optional<Order> findById(OrderId id) {
return jpaRepo.findById(id.getValue())
.map(OrderMapper::toDomain); // PO → DO
}
}DO → PO(仓储完成)
public void save(Order order) {
OrderPO po = OrderMapper.toPO(order); // DO → PO
jpaRepo.save(po);
}转换位置约定
| 转换 | 位置 | 工具 |
|---|---|---|
| PO ↔ DO | Infrastructure Repository | Mapper/Converter |
| DO → DTO | Application Assembler | Assembler |
| DTO ↔ VO | Adapter Controller | WebConverter |
一个 DO → 多个 DTO
同一个 Order 聚合根可以产出:
OrderDetailDTO— 详情页(完整字段)OrderSummaryDTO— 列表页(精简字段)OrderExportDTO— 导出(特定字段)
每个 DTO 面向特定场景,避免"万能 DTO"。
事件驱动 API 设计
领域事件通过异步消息在限界上下文间传播:
- 事件命名:{Aggregate}.{Action}.Occurred(如 Order.Paid.Occurred)
- 事件契约:eventId + eventType + aggregateId + occurredAt + payload
- 投递保障:至少一次投递(At-Least-Once),消费者实现幂等去重
- 事件 API:POST /api/v1/events/publish — 发布事件
Unified Response Format Specification
Core Design
Every API response follows the same envelope format (Result<T>):
{
"code": 0,
"message": "success",
"data": { /* type-specific payload */ },
"requestId": "req-abc123",
"timestamp": "2024-01-15T10:30:00Z"
}Response Type Definitions
Success Response: Single Object
{
"code": 0,
"message": "success",
"data": {
"orderId": "ORD-2024-001",
"status": "PAID",
"totalAmount": "99.00",
"items": [
{ "itemId": "ITEM-001", "productName": "T-Shirt", "quantity": 2, "price": "49.50" }
]
},
"requestId": "req-abc123"
}Success Response: Paginated List
{
"code": 0,
"message": "success",
"data": {
"records": [
{ "orderId": "ORD-2024-001", "status": "PAID", "totalAmount": "99.00" },
{ "orderId": "ORD-2024-002", "status": "DRAFT", "totalAmount": "150.00" }
],
"total": 100,
"page": 1,
"pageSize": 20,
"totalPages": 5
},
"requestId": "req-abc123"
}Success Response: No Content (204)
HTTP/1.1 204 No Content
Content-Length: 0Used for DELETE operations — no response body.
Error Response: Business Error
{
"code": 40001,
"message": "订单状态不允许支付",
"detail": "当前状态:CANCELLED,可支付状态:DRAFT",
"requestId": "req-abc123",
"timestamp": "2024-01-15T10:30:00Z"
}Error Response: Validation Error
{
"code": 40002,
"message": "参数校验失败",
"detail": [
{ "field": "amount", "message": "金额不能为负数" },
{ "field": "customerId", "message": "客户ID不能为空" },
{ "field": "items", "message": "订单项不能为空" }
],
"requestId": "req-abc123"
}Error Response: Not Found
{
"code": 40401,
"message": "订单未找到",
"detail": "orderId: ORD-2024-999",
"requestId": "req-abc123"
}Error Response: Conflict / Concurrent Modification
{
"code": 40901,
"message": "数据已被其他操作修改",
"detail": "预期版本: 3, 当前版本: 5",
"requestId": "req-abc123"
}Error Response: System Error (500)
{
"code": 50000,
"message": "系统内部错误",
"requestId": "req-abc123"
}Error Code System
Code Range Allocation
| Code Range | Category | HTTP Status | Description |
|---|---|---|---|
| 0 | Success | 200/201 | Success |
| 40001-40099 | Business Rule Violation | 400 | Domain rule prevented operation |
| 40100-40199 | Authentication | 401 | Missing or invalid credentials |
| 40300-40399 | Authorization | 403 | Insufficient permissions |
| 40401-40499 | Not Found | 404 | Resource not found |
| 40901-40999 | Conflict | 409 | Optimistic lock / duplicate |
| 41201-41299 | Precondition Failed | 412 | Version mismatch |
| 42901-42999 | Rate Limit | 429 | Too many requests |
| 50000-50099 | System Error | 500 | Unexpected internal error |
| 50301-50399 | Service Unavailable | 503 | Downstream service unavailable |
Code Naming Convention
XXYYY
│└── Specific error number (001-999)
└── Category:
0 = Success
40 = Client error (4xx)
41 = Auth (401)
43 = Forbidden (403)
44 = Not Found (404)
49 = Conflict (409)
42 = Rate limit (429)
50 = Server error (500)
53 = Service unavailable (503)Response Wrapper Implementation
Java
public class Result<T> {
private int code;
private String message;
private T data;
private String detail;
private String requestId;
private String timestamp;
// Success
public static <T> Result<T> success(T data) {
return new Result<>(0, "success", data, null, null, now());
}
// Error with detail
public static <T> Result<T> error(int code, String message, Object detail) {
return new Result<>(code, message, null, detail, null, now());
}
// System error with requestId
public static <T> Result<T> systemError(String requestId) {
return new Result<>(50000, "系统内部错误", null, null, requestId, now());
}
}TypeScript
interface ApiResponse<T> {
code: number;
message: string;
data?: T;
detail?: any;
requestId: string;
timestamp: string;
}
interface PaginatedData<T> {
records: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// Usage
type OrderListResponse = ApiResponse<PaginatedData<OrderSummary>>;
type OrderDetailResponse = ApiResponse<OrderDetail>;Exception Handling Strategy
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<?> handleBusiness(BusinessException e) {
return Result.error(e.getCode(), e.getMessage(), e.getDetail());
}
@ExceptionHandler(ValidationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<?> handleValidation(ValidationException e) {
return Result.error(40002, "参数校验失败", e.getErrors());
}
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Result<?> handleNotFound(ResourceNotFoundException e) {
return Result.error(40401, e.getMessage(), e.getResourceId());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<?> handleSystem(Exception e, HttpServletRequest request) {
log.error("System error", e);
return Result.systemError(request.getAttribute("requestId").toString());
}
}Guidelines
1. Always wrap responses: All API responses use Result<T> envelope. Only health checks and file downloads are exempt. 2. Consistent error codes: Same error across endpoints returns same code. No code reuse across different error types. 3. No stack traces: Error responses never include stack traces. Use requestId for server-side log correlation. 4. Include requestId: Every response includes a requestId for debugging. Attach to logs for traceability. 5. ISO 8601 timestamps: All datetime values use ISO 8601 format with timezone. 6. Avoid null in data: If no data, omit the data field entirely, or return an empty object {}.
API Versioning Strategies
Strategy Comparison
| Strategy | Example | Pros | Cons | Best For |
|---|---|---|---|---|
| URL Path | /api/v1/orders | Intuitive, CDN-friendly, easy to test | URL pollution | Most projects |
| Request Header | Accept: app.vnd.company.v2+json | Clean URLs, semantic | Hard to test, poor tooling | Mature API platforms |
| Query Parameter | /api/orders?version=2 | Simple to implement | Caching issues, accidental param | Temporary/testing |
| Content Negotiation | Accept: app/json;version=2 | RESTful standard | Poor client adoption | REST purists |
Recommended: URL Path Versioning
/api/v1/orders → Version 1 endpoints
/api/v2/orders → Version 2 endpoints
/api/v1/orders/{id}
/api/v2/orders/{id}Rationale:
- Most intuitive for API consumers
- Best Swagger/OpenAPI compatibility (each version = separate spec file)
- CDN can cache by URL path
- Easy to test in browser, curl, Postman
Version Lifecycle Management
v1.0 (active) → v2.0-alpha → v2.0-beta → v2.0 (GA)
[launch] [coexist] [coexist] [stable]
↓ ↓
v1.x (maintenance) v1 (sunset)
Bug fixes only Deprecation notice
Removed after 6 monthsMigration Process
Phase 1: Dual-run (v1 + v2)
├── v1: Existing clients continue
├── v2: New clients start
└── Migration guide: Document all breaking changes
Phase 2: Deprecate v1
├── Add "Deprecated" header to v1 responses
├── Extend migration deadline via announcement
└── Monitor v1 traffic decline
Phase 3: Sunset v1
├── Return 410 Gone for v1 endpoints
├── Remove v1 code and deployment
└── Archive v1 OpenAPI specWhat Constitutes a Breaking Change (Version Bump)
Major Version Bump Required (Breaking)
- Remove a field from response
- Rename a field
- Change field type (string → number)
- Make required field optional (clients may rely on it)
- Change endpoint URL
- Change error codes or response structure
- Add required field to requestMinor/Patch Version (Non-Breaking)
- Add new endpoint (v1 + new endpoint is compatible)
- Add optional field to response (clients ignore unknown fields)
- Add optional field to request (server uses default if absent)
- Change error message text (not code or structure)
- Performance improvement
- Bug fix (no contract change)API Version OpenAPI Examples
v1 OpenAPI
openapi: 3.0.3
info:
title: Order Service API
version: 1.0.0
servers:
- url: https://api.example.com/api/v1
paths:
/orders:
get:
summary: List orders (v1 - basic)
parameters:
- name: status
in: query
schema:
type: string
responses:
'200':
description: Order list
content:
application/json:
schema:
$ref: '#/components/schemas/OrderV1'
components:
schemas:
OrderV1:
type: object
properties:
id: { type: integer }
status: { type: string }
amount: { type: number }v2 OpenAPI
openapi: 3.0.3
info:
title: Order Service API
version: 2.0.0
servers:
- url: https://api.example.com/api/v2
paths:
/orders:
get:
summary: List orders (v2 - enhanced pagination + filters)
parameters:
- name: status
in: query
schema:
type: string
enum: [DRAFT, PAID, SHIPPED, CANCELLED]
- name: page
in: query
schema: { type: integer, default: 1 }
- name: size
in: query
schema: { type: integer, default: 20 }
responses:
'200':
description: Paginated order list
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedOrderV2'
components:
schemas:
OrderV2:
type: object
properties:
orderId: { type: string } # Renamed: id → orderId
status: { type: string }
totalAmount: { type: string } # Changed type: number → string
currency: { type: string } # NEW field
items: # NEW field
type: array
items:
$ref: '#/components/schemas/OrderItemV2'Client Migration Guide Template
# Migrating from Order API v1 to v2
## Breaking Changes
1. `id` → `orderId` (field renamed)
2. `amount` → `totalAmount` + `currency` (split into two fields)
3. `amount` type: number → string (precision improvement)
4. Response now `{code, message, data}` wrapped
## Migration Steps
1. Update your client to parse `orderId` instead of `id`
2. Use `totalAmount` (string) instead of `amount` (number)
3. Use `currency` field for currency code
4. Parse response from flat `{id, ...}` to wrapped `{code, message, data}`
## Timeline
- v1 deprecation notice: 2024-Q2
- v1 sunset: 2024-Q4
- v2 mandatory: 2025-Q1BFF (Backend for Frontend) Design Pattern
What is BFF?
BFF is a dedicated backend layer for each frontend application. Instead of having one API serving all clients, each platform (Web, iOS, Android, MiniApp) has its own backend that tailors data and behavior specifically for that platform.
When to Use BFF
| Scenario | Use BFF? |
|---|---|
| Single web app only | No — API Gateway is sufficient |
| Web + Mobile App | Yes — different data needs |
| Web + MiniApp + WeChat | Yes — platform-specific formats |
| Multiple frontend teams | Yes — independent evolution |
| Public API for third-party | Public API → API Gateway, not BFF |
BFF Responsibilities in Detail
1. Data Aggregation
Without BFF:
Frontend → /api/orders/{id} → Order detail (needs 3 calls)
→ /api/payments/{orderId} → Payment info
→ /api/shipping/{orderId} → Shipping status
Result: 3 HTTP calls, client-side aggregation
With BFF:
Frontend → /api/web-bff/order-detail/{id} → Single response
BFF internally calls:
→ Order Service → OrderDO
→ Payment Service → PaymentDTO
→ Shipping Service → ShippingDTO
BFF combines them into OrderDetailVO
Result: 1 HTTP call, server-side aggregation2. Format Adaptation
// Web BFF response (rich, full data)
{
"orderId": "ORD-2024-001",
"status": "PAID",
"totalAmount": "99.00",
"items": [
{ "name": "T-Shirt", "imageUrl": "https://cdn.example.com/tshirt.jpg",
"description": "Premium cotton T-Shirt", "quantity": 2, "price": "49.50" }
],
"customer": { "name": "张三", "email": "zhang@example.com" },
"paymentMethod": "微信支付",
"estimatedDelivery": "2024-01-20",
"actions": ["cancel", "return"]
}
// Mobile BFF response (minimal, paginated)
{
"orderId": "ORD-2024-001",
"status": "PAID",
"totalAmount": "99.00",
"itemCount": 2,
"estimatedDelivery": "2024-01-20",
"action": "cancel"
}3. Protocol Translation
Internal (gRPC/Protobuf):
Order service proto: Order { id uint64, status OrderStatus, ... }
Payment service proto: Payment { id uint64, amount Money, ... }
External (REST/JSON) via BFF:
GET /api/web-bff/order-detail/{id}
→ JSON: { "orderId": "ORD-2024-001", "status": "PAID", ... }BFF Directory Structure (Hexagonal)
bff-web/
├── adapter/ # BFF 适配器层
│ ├── inbound/ # 对外暴露的 REST 端点
│ │ ├── controller/
│ │ └── dto/ # 对外 VO(面向前端页面)
│ └── outbound/ # 调用下游服务的客户端
│ ├── order-service/ # gRPC/REST client → Order Service
│ ├── payment-service/ # gRPC/REST client → Payment Service
│ └── product-service/ # gRPC/REST client → Product Service
├── application/ # 聚合编排层
│ ├── service/ # 组合多个下游服务
│ └── assembler/ # 组装 VO
└── config/ # 配置BFF Best Practices
1. One BFF per frontend team: Each team owns their BFF independently 2. BFF is thin, not thick: Aggregation only, no business logic 3. No direct DB access: BFF calls downstream services, never the database 4. Handle partial failures: If one downstream service fails, return partial data with error indicators 5. Cache aggressively: BFF responses are view-specific and cacheable by URL 6. Separate deployments: BFF and downstream services deploy independently
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| BFF contains business logic | Duplication across BFFs | Move to domain service |
| One BFF for all platforms | Single point of change | Create per-platform BFF |
| BFF calls BFF | Request chain, latency | Restructure orchestration |
| BFF accesses DB directly | Bypasses domain rules | Always go through services |
CQRS API Design Pattern
Core Principle
CQRS (Command Query Responsibility Segregation) separates write operations from read operations at the API level. This is distinct from CQRS at the architecture level — API-level CQRS focuses on endpoint design, request/response structure, and DTO separation.
Endpoint Patterns
Command Endpoints (Write)
Commands represent intent — they change system state.
POST /api/v1/orders → CreateOrderCommand
PUT /api/v1/orders/{id}/confirm → ConfirmOrderCommand
PUT /api/v1/orders/{id}/ship → ShipOrderCommand
DELETE /api/v1/orders/{id} → CancelOrderCommandCharacteristics:
- Non-idempotent operations use POST
- Idempotent operations use PUT with idempotency key
- Response contains the created/modified resource summary
- Always validate business rules before applying changes
Query Endpoints (Read)
Queries represent questions — they return system state without side effects.
GET /api/v1/orders/{id} → OrderDetailDTO
GET /api/v1/orders?status=PAID&page=1 → OrderSummaryDTO[]
GET /api/v1/orders/{id}/items → OrderItemDTO[]Characteristics:
- Always idempotent and safe (no side effects)
- Can be cached aggressively
- Response structure may differ significantly from command response
- Can use materialized views or read models
DTO Separation Rules
| Aspect | Command DTO | Query DTO |
|---|---|---|
| Naming | CreateOrderRequest | OrderDetailDTO |
| Direction | Input (request body) | Output (response body) |
| Fields | What's needed to execute the command | What's needed to display |
| Validation | Business rules + format | None (read-only) |
| Mutability | May be mutable | Immutable |
Idempotency for Command APIs
POST /api/v1/orders
Headers:
Idempotency-Key: uuid-v4-unique-key
# If first request → 201 Created
# If retry with same key → 200 OK (same resource, no duplicate)
# If different key for same data → 409 Conflict (idempotency check)Implementation: 1. Client generates UUID as idempotency key 2. Server stores (key, result) in cache with TTL 3. On duplicate key → return cached result 4. Cache TTL must exceed max retry window (recommended: 24h)
Materialized View Pattern
For query performance, maintain dedicated read models:
┌─────────────┐ Domain Events ┌─────────────┐
│ Command DB │ ─────────────────────→ │ Read Model │
│ (normalized)│ │ (denormalized)│
└─────────────┘ └─────────────┘
│ │
▼ ▼
Order Aggregate OrderDetailMV (flat JSON)
├── Order (root) ├── id, status, total
├── Items[] ├── customerName, email
├── Payment ├── items[ {name, qty, price} ]
└── Customer (ID ref) ├── paymentMethod, paidAt
└── shippingAddress, statusThe Read Model is updated asynchronously via domain events, allowing query-optimized structures independent of the domain model.
Data Access Layer API 设计原则
DAO 层接口设计:
- Repository 隔离:领域 Repository 接口定义在 Domain 层,实现在 Infrastructure 层
- CQS 分离:查询 Repository 和命令 Repository 接口分离
- 分页抽象:统一 Pageable/Page 泛型,避免泄露 ORM 分页模型
- 规约模式:Specification 模式封装复杂查询条件,UserSpecification.withStatus(Status.PAID).and(between(from, to))
- 延迟加载:设计 API 时避免 N+1 查询,使用 Fetch Join 或 EntityGraph
API 幂等设计
为什么需要幂等
在分布式系统中,网络超时、客户端重试、MQ 重复消费都会导致同一请求被多次执行。幂等设计确保重复请求不产生副作用。
哪些操作需要幂等
| 方法 | 天然幂等? | 说明 |
|---|---|---|
| GET | ✅ | 不修改状态 |
| PUT | ✅ | 全量替换,多次执行结果相同 |
| DELETE | ✅ | 删除已删除的资源返回相同结果 |
| POST | ❌ | 每次执行创建新资源,必须幂等处理 |
四种幂等实现方案
1. 幂等键(Idempotency-Key)— 最推荐
客户端在请求头中传入唯一键,服务端缓存结果去重。
POST /api/v1/orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000流程:
请求到达 → 查缓存 (key → result)
├── 命中 → 直接返回缓存结果(200 OK)
└── 未命中 → 执行业务逻辑 → 存储 (key, result) → 返回结果注意事项:
- 缓存 TTL 必须超过最大重试窗口(推荐 24h)
- 使用 Redis + TTL 实现
- 幂等键空间应足够大(UUID v4)
- 幂等键的响应结果不可变更:第一次成功 → 永远返回成功;第一次失败 → 后续重试继续执行(业务有状态时需要结合状态机)
public class IdempotencyFilter implements Filter {
private final RedisTemplate<String, String> redis;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
HttpServletRequest req = (HttpServletRequest) request;
String idempotencyKey = req.getHeader("Idempotency-Key");
if (idempotencyKey != null && "POST".equalsIgnoreCase(req.getMethod())) {
String cached = redis.opsForValue().get("idempotent:" + idempotencyKey);
if (cached != null) {
// 返回缓存结果
response.getWriter().write(cached);
return;
}
}
chain.doFilter(request, response);
}
}2. 业务唯一索引 — 简单可靠
利用数据库唯一约束防止重复:
-- 订单号唯一约束
ALTER TABLE orders ADD UNIQUE INDEX uk_order_no (order_no);try {
orderRepository.save(order);
} catch (DuplicateKeyException e) {
// 重复订单号 → 返回已创建的订单信息
return orderRepository.findByOrderNo(order.getOrderNo());
}3. 状态机 — 防重入
业务状态有明确流转路径,已处理状态不可重复触发:
public class Order {
private OrderStatus status;
public void confirm() {
if (this.status != OrderStatus.DRAFT) {
// DRAFT → CONFIRMED 只允许一次
throw new BusinessException(40001, "当前状态不允许确认",
"当前:" + this.status + ",需要:DRAFT");
}
this.status = OrderStatus.CONFIRMED;
}
public void pay() {
if (this.status != OrderStatus.CONFIRMED) {
throw new BusinessException(40001, "当前状态不允许支付");
}
this.status = OrderStatus.PAID;
}
}4. 业务幂等(乐观锁)— 无额外存储
利用数据库条件更新:
-- 扣减库存:stock >= 1 确保不会超卖
UPDATE product SET stock = stock - 1 WHERE id = ? AND stock >= 1;
-- 乐观锁版本号
UPDATE orders SET status = 'PAID', version = version + 1
WHERE id = ? AND version = ?;幂等策略选型矩阵
| 场景 | 推荐方案 |
|---|---|
| 创建订单(POST) | 幂等键 + 订单号唯一索引 |
| 支付回调 | 业务唯一索引(支付单号) |
| 状态变更(PUT) | 状态机 + 乐观锁 |
| 库存扣减 | 条件更新(stock >= n) |
| MQ 消息消费 | 消息 ID 幂等键 + 去重表 |
幂等键最佳实践
1. 幂等键由客户端生成:UUID v4,确保唯一性 2. 幂等键作用域:按 (key, endpoint) 区分,不同端点可用相同 key 3. 结果缓存不可变:第一次成功→永远返回成功结果 4. TTL 设计:至少 24h,最长 7 天(取决于业务重试窗口) 5. 幂等键大小:建议 64 字节以内,作为 Redis key 需控制长度
幂等键在 OpenAPI 中的定义
paths:
/orders:
post:
parameters:
- name: Idempotency-Key
in: header
required: false
schema:
type: string
format: uuid
description: 客户端幂等键,防止重复创建
responses:
'201':
description: 首次创建
'200':
description: 幂等返回(重复请求使用相同幂等键)
headers:
Idempotent-Replayed:
schema:
type: boolean
description: 标识该响应是重放结果API 分页与过滤设计
分页方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| Offset/Page | ?page=1&size=20 | 实现简单,随机跳页 | 深分页性能差,数据偏移 | 管理后台、小数据集 |
| Cursor | ?cursor=eyJpZCI6MTAwfQ==&limit=20 | 稳定性能,实时数据准确 | 不能随机跳页 | 用户端列表、实时数据 |
| Keyset | ?after_id=100&limit=20 | 最快性能(索引) | 不能跳页,排序受限 | 只按 ID 排序的场景 |
| Seek | ?offset_id=100&size=20 | Keyset + 灵活排序 | 实现稍复杂 | 社交媒体 Feeds |
推荐方案
- 管理端:Offset/Page 分页(需要跳页功能)
- 用户端:Cursor 分页(实时数据场景)
- 大数据量:Keyset/Seek 分页(避免 offset 导致的性能退化)
Offset/Page 分页规范
请求参数
GET /api/v1/orders?page=1&size=20&sort=createdAt,desc| 参数 | 默认值 | 说明 |
|---|---|---|
page | 1 | 页码,从 1 开始 |
size | 20 | 每页条数,最大 100 |
sort | createdAt,desc | 排序字段和方向 |
响应格式
{
"code": 0,
"message": "success",
"data": {
"records": [
{ "orderId": "ORD-2024-001", "status": "PAID", "totalAmount": "99.00" }
],
"total": 100,
"page": 1,
"pageSize": 20,
"totalPages": 5
}
}实现
public class PageRequest {
private int page = 1; // 页码,从 1 开始
private int size = 20; // 每页条数
private String sort; // 排序:createdAt,desc
public long getOffset() {
return (long) (page - 1) * size;
}
public Sort getSort() {
// 解析 sort 参数 → Spring Sort 对象
}
}
public class PageResult<T> {
private List<T> records;
private long total;
private int page;
private int pageSize;
private int totalPages;
public static <T> PageResult<T> of(List<T> records, long total, PageRequest request) {
PageResult<T> result = new PageResult<>();
result.records = records;
result.total = total;
result.page = request.getPage();
result.pageSize = request.getSize();
result.totalPages = (int) Math.ceil((double) total / request.getSize());
return result;
}
}Cursor 分页规范
请求参数
GET /api/v1/orders?cursor=eyJjcmVhdGVkQXQiOiIyMDI0LTAxLTE1VDEwOjMwOjAwWiIsImlkIjoiT1JELTIwMjQtMDEwIn0=&limit=20| 参数 | 默认值 | 说明 |
|---|---|---|
cursor | 无 | 上一页最后一条记录的编码标识 |
limit | 20 | 每页条数 |
sort | createdAt,desc | 排序(必须与 cursor 编码一致) |
响应格式
{
"code": 0,
"message": "success",
"data": {
"records": [
{ "orderId": "ORD-2024-011", "status": "PAID", "createdAt": "2024-01-16T10:30:00Z" }
],
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI0LTAxLTE2VDEwOjMwOjAwWiIsImlkIjoiT1JELTIwMjQtMDExIn0=",
"hasMore": true
}
}Cursor 编码实现
public class CursorCodec {
private static final ObjectMapper mapper = new ObjectMapper();
// 将游标对象编码为 Base64
public static String encode(Map<String, Object> cursorFields) {
try {
return Base64.getUrlEncoder().encodeToString(
mapper.writeValueAsString(cursorFields).getBytes());
} catch (JsonProcessingException e) {
throw new RuntimeException("Cursor encode failed", e);
}
}
// 解码游标
public static Map<String, Object> decode(String cursor) {
try {
byte[] bytes = Base64.getUrlDecoder().decode(cursor);
return mapper.readValue(bytes, Map.class);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid cursor", e);
}
}
}过滤设计
基础过滤
GET /api/v1/orders?status=PAID&customerId=USR-001
GET /api/v1/orders?status=PAID,SHIPPED # 多值过滤(逗号分隔)
GET /api/v1/orders?createdAtFrom=2024-01-01&createdAtTo=2024-01-31 # 范围过滤高级过滤(复杂查询)
// POST /api/v1/orders/search
{
"filters": [
{ "field": "status", "operator": "in", "value": ["PAID", "SHIPPED"] },
{ "field": "totalAmount", "operator": "gte", "value": 100 },
{ "field": "createdAt", "operator": "between", "value": ["2024-01-01", "2024-01-31"] }
],
"sort": { "field": "createdAt", "order": "desc" },
"page": { "page": 1, "size": 20 }
}| 操作符 | 说明 | SQL 对应 |
|---|---|---|
eq | 等于 | = |
neq | 不等于 | != |
in | 包含 | IN |
nin | 不包含 | NOT IN |
gt / gte | 大于 / 大于等于 | > / >= |
lt / lte | 小于 / 小于等于 | < / <= |
between | 范围 | BETWEEN |
like | 模糊 | LIKE |
contains | 包含(数组/字符串) | @> / LIKE %...% |
字段选择
允许客户端只请求需要的字段,减少传输量:
GET /api/v1/orders?fields=orderId,status,totalAmount{
"data": {
"orderId": "ORD-2024-001",
"status": "PAID",
"totalAmount": "99.00"
}
}分页最佳实践
1. 统一分页格式:所有列表接口使用相同的分页响应结构(records/total/page/pageSize) 2. 限制最大 size:size 上限 100,防止大查询压垮数据库 3. 深分页优化:超过 10000 条 offset 时建议切换到 Cursor 或 Keyset 4. 总记录数缓存:total 可以使用缓存,减少 COUNT 查询 5. 排序字段加索引:ORDER BY createdAt 必须有对应索引 6. 搜索端点分离:复杂搜索用 POST /search 专用端点,不混入 GET 查询 7. 默认排序:始终提供默认排序,避免分页结果不稳定
API 端点与数据对象命名规范
命令 API(写操作)
POST /api/v1/orders # 创建订单
PUT /api/v1/orders/{id}/confirm # 确认订单
DELETE /api/v1/orders/{id} # 取消订单
命名规则:
- 使用名词复数(/orders 而非 /order)
- 写操作用动词后缀(confirm, cancel, approve, ship)
- 避免深层嵌套(最多 2 层:/orders/{id}/items)查询 API(读操作)
GET /api/v1/orders/{id} # 订单详情
GET /api/v1/orders?status=PAID # 订单列表
GET /api/v1/orders/{id}/items # 订单项列表
命名规则:
- 资源驱动命名
- 查询参数:?status=PAID&page=1&size=20
- 避免动词:GET /getOrders ✗ → GET /orders ✓CQRS 读写端点对照
命令(写):
POST /orders → CreateOrderCommand
PUT /orders/{id}/confirm → ConfirmOrderCommand
DELETE /orders/{id} → CancelOrderCommand
查询(读):
GET /orders/{id} → OrderDetailDTO(物化视图)
GET /orders?status=PAID → OrderSummaryDTO[](读模型)错误码规范
| Code | HTTP Status | Meaning | Retryable |
|---|---|---|---|
| 0 | 200/201 | 成功 | — |
| 40001 | 400 | OrderStatus 不允许支付 | No |
| 40002 | 400 | 参数校验失败 | No |
| 40401 | 404 | 聚合未找到 | No |
| 40901 | 409 | 并发冲突(乐观锁) | Yes |
| 50000 | 500 | 系统内部错误 | Yes |
事件契约字段规范
每个领域事件必须包含:
eventId:UUID,全局唯一eventType:事件类型,如order.paidaggregateId:来源聚合 IDoccurredAt:发生时间戳schemaVersion:schema 版本号correlationId:关联 ID(跨服务追踪)payload:业务数据,序列化为 JSON
事件版本兼容策略
| 策略 | 做法 | 适用 |
|---|---|---|
| 只增字段 | 新增字段设默认值 | 兼容升级 |
| 升级主版本 | 新 topic + 旧 topic 保留 | API 签名变化 |
| 双写过渡 | v1 + v2 同时发布,消费者升级后下线 v1 | 平滑迁移 |
统一响应格式
// 成功(单体)
{ "code": 0, "message": "success", "data": { "id": "...", "status": "PAID" } }
// 成功(分页)
{ "code": 0, "message": "success", "data": { "records": [...], "total": 100, "page": 1, "pageSize": 20 } }
// 错误
{ "code": 40001, "message": "订单状态不允许支付", "detail": "当前:CANCELLED,可支付:DRAFT" }
// 校验错误
{ "code": 40002, "message": "参数校验失败", "detail": [{ "field": "amount", "message": "金额不能为负数" }] }API 限流设计
差异化限流策略:
- Command API:50 req/s(写操作成本高)
- Query API:200 req/s(读操作可放宽)
- Auth API:10 req/s(防暴力破解)
- 全局:1000 req/s
实现方式:令牌桶算法(Token Bucket),支持突发流量。 限流响应:429 Too Many Requests + Retry-After header。