引言
在上一篇文章《NestJS 架构设计:5 分钟抓住 DDD 的命脉》中,我们介绍了 DDD (领域驱动设计)的经典分层架构。其中,应用层与领域层分别对应两类关键服务:
- 应用服务(Application Service / Usecase):位于应用层,负责流程编排;
- 领域服务(Domain Service):位于领域层,承载核心业务规则。
简单来说:应用服务表达用户想做什么,领域服务表达业务该怎么做。
以创建订单为例:
- 应用服务:用户点击结算 → 计算金额 → 发起支付 → 创建订单 → 通知用户;
- 领域服务:金额计算、支付处理、订单状态变更等具体业务规则。
应用服务负责串联整个用例流程 ,而领域服务则专注于每个环节的业务实现。
应用服务(Usecase)
应用服务是表达具体业务场景(如取消订单、创建支付)的主要载体,它对应用户发起的一个完整操作流程。
核心职责
应用服务本质上是一个协调器,其职责包括:
- 协调多个领域对象与领域服务;
- 控制业务用例的执行顺序;
- 组装并返回最终结果。
尽管应用层不应包含核心业务逻辑,但它可以合理处理以下非核心但必要的横切关注点:权限校验、事务控制、结果组装、发送通知(邮件、消息等非核心业务)。
代码示例
我们来看一个真实 NestJS 项目中的应用服务 CancelOrderUsecase:
js
import { OrderStatus } from '@/modules/payment/client/constant/Order';
// ... 省略其他 imports
@Injectable()
export class CancelOrderUsecase {
@Inject()
private readonly dataAssembler: DataAssembler;
@Inject()
private readonly cancelOrderByOrderIdService: CancelOrderByOrderIdService;
@Inject()
private readonly getOrderByOrderIdService: GetOrderByOrderIdService;
@Inject()
private readonly validatePaymentRelatedEntityPrivilegeService: ValidatePaymentRelatedEntityPrivilegeService;
@Inject()
private readonly stripeService: StripeService;
async execute(orderId: number, userId: number) {
// 1. 获取订单信息
const order = await this.getOrderByOrderIdService.execute(orderId);
// 2. 状态校验
if (order.status === OrderStatus.Pending) throw ExceptionFactory.bizException(ErrorCode.INVALID_PARAM);
// 3. 权限验证
await this.validatePaymentRelatedEntityPrivilegeService.execute({
entityId: order.entityId,
entityType: order.entityType,
userId,
});
// 4. 调用领域服务执行核心业务
const cancelResult = await this.cancelOrderByOrderIdService.execute(orderId);
// 5. 处理第三方服务(非核心业务)
if (this.configService.bizConfig.isGlobalEdition && order.paymentInfo.invoice) {
try {
await this.stripeService.voidInvoice(order.paymentInfo.invoice.id);
} catch (error) {
// 第三方调用失败不影响主流程
}
}
// 6. 发送通知(非核心业务)
this.sendNotification(order, userId);
// 7. 组装返回结果
const result = await this.dataAssembler.assemble<EntityDTO<Order>>({
domainObject: cancelResult,
});
return SingleResponse.of(result);
}
}
从以上代码我们可以知道应用服务的特点:
- 不包含业务逻辑 :订单取消的核心逻辑由
CancelOrderByOrderIdService领域服务实现; - 编排多个服务:依次调用查询、验证、取消等服务;
- 处理非核心业务:发送通知、调用 Stripe 第三方 API;
- 组装返回结果 :通过
DataAssembler将领域对象转换为 DTO。
领域服务(Domain Service)
并非所有业务逻辑都能自然地归属到某个实体 或值对象 中。当一个操作涉及多个聚合、需要协调多个领域对象,或本身是无状态的业务规则时,我们就将其抽为领域服务。
它是领域层的重要组成部分,专注于实现核心业务逻辑,但仅限于那些不属于任何单一领域对象的场景。
核心原则
领域服务的设计遵循以下核心原则:
- 单一职责:每个服务专注于一个具体的业务操作;
- 无状态:状态由领域对象(如实体 Entity)保存,领域服务本身不持有状态;
- 可复用性:可以被多个应用服务调用以执行相同的业务逻辑;
- 纯粹性:只处理核心业务逻辑,避免混入非业务相关的代码。
什么时候使用领域服务?
- 当需要执行一个显著的业务操作过程时;
- 对领域对象进行转换或计算;
- 当业务逻辑涉及多个领域对象,并且需要返回一个值对象作为结果时。
代码示例
我们同样来看一个真实 NestJS 项目中的领域服务 TransferUserCreditToTeamService(用户积分转团队):
js
async execute({
userId,
teamId,
transactionId,
userCreditHistoryData,
teamCreditHistoryData,
amount,
}: {
userId: number;
teamId: number;
transactionId: string;
amount: number;
userCreditHistoryData: Record<string, unknown>;
teamCreditHistoryData: Record<string, unknown>;
}) {
// 开启事务前的准备工作
const knex = this.teamCreditRepository.getKnex();
await this.validateRedisLock(teamId);
await this.teamCreditRepository.findOrCreateOne(teamId);
const userCredit = await this.userCreditRepository.findOrCreateOne(userId);
// 业务规则校验:余额是否足够
if (userCredit.amount < amount) {
throw Error(`user ${userId} credit not enough`);
}
// 创建 Stripe 余额记录
const balanceTransactionId = await this.createStripeCustomerBalance(...);
try {
await knex.transaction(async (trx) => {
// 事务内的业务逻辑:
// 1. 检查流水是否已存在(幂等性)
// 2. 扣减用户积分
// 3. 增加团队积分
// 4. 校验最终状态
});
} catch (error) {
throw error;
}
// 发布领域事件
await this.domainEventPublisher.publishSync(
new UserCreditHistoryCreatedEvent(...)
);
await this.domainEventPublisher.publishSync(
new TeamCreditHistoryCreatedEvent(...)
);
}
这个领域服务具体展示了以下几个方面:
- 业务规则:如余额校验、流水幂等性检查;
- 事务处理:确保转账过程中的原子性,保障数据一致性;
- 领域事件:过发布领域事件通知其他聚合关于积分变动的信息。
实体(Entity)与领域服务的职责划分
在前面介绍中,我们提到了实体(Entity)和值对象 ,在 DDD 中,业务逻辑应优先内聚在实体或值对象中 。只有当某个行为无法自然归属于单一实体(例如涉及多个聚合、无状态操作、或跨对象协作)时,才将其提取为领域服务。
那么,具体如何判断一段逻辑该放在哪里?可以从以下几个维度进行权衡:
| 维度 | 实体(Entity) | 领域服务(Domain Service |
|---|---|---|
| 状态依赖 | 逻辑强依赖于自身状态(如实例属性) | 无状态,不持有数据,仅基于输入参数执行操作 |
| 作用范围 | 仅操作当前实体实例 | 通常涉及多个实体、聚合或值对象 |
| 聚合边界 | 不应跨越聚合边界(即不直接操作其他聚合根) | 可协调多个聚合之间的交互(但需谨慎)多个领域对象 |
| 内聚性 | 行为是该实体天然具备的能力(如 order.cancel()) | 行为是外部赋予的协作逻辑(如 transferCredit(user, team)) |
来看一个真实 NestJS 项目中的 Order 实体中的行为:
js
@Property({ persist: false })
get paymentInfo(): typeof this._paymentInfo {
try {
const databaseValue =
typeof this._paymentInfo === 'string'
? JSON.parse(this._paymentInfo)
: this._paymentInfo;
const clone = cloneDeep(databaseValue);
// 基于当前订单 ID 生成预览路径(强依赖 this.id)
if (
clone.credential?.receiptFiles?.length > 0
) {
clone.credential.receiptFiles = clone.credential.receiptFiles.map((item) =>
getCredentialImagePreviewPath(String(this.id), item)
);
}
return clone;
} catch {
return {};
}
}
set paymentInfo(value) {
const parsed = safeJsonParse(value, {});
// 清理路径前缀(同样依赖 this.id)
if (parsed.credential?.receiptFiles?.length > 0) {
parsed.credential.receiptFiles = parsed.credential.receiptFiles.map((item) =>
removeCredentialImagePrefix(String(this.id), item)
);
}
this._paymentInfo = parsed;
}
这段逻辑:
- 完全基于
this.id和this._paymentInfo运行; - 不调用其他聚合或服务;
- 是对自身持久化数据的视图转换,而非业务决策。
因此,它非常适合放在实体内部,既保持了内聚性,又避免了不必要的服务膨胀。
最后,应用服务 和领域服务 的区别已介绍完毕,阅读本篇文章最好结合上篇文章《NestJS 架构设计:5 分钟抓住 DDD 的命脉》一起阅读,可能会更好理解,如果你觉得有什么地方遗漏、疑惑,欢迎评论讨论。