项目背景:安康保险核心系统重构
公司原有的保险核心系统是一个庞大的单体架构,新保险产品上线慢、核保逻辑错综复杂、理赔处理效率低下。我们决定采用DDD对核心系统进行重构。
第一步:战略设计 - 理解保险关键业务场景
1. 事件风暴与统一语言
我们与精算师、核保员、理赔员、销售团队进行多次工作坊,提炼出核心领域事件:
-
保险产品已创建 -
投保申请已提交 -
投保申请已核保(可能通过、拒保、加费) -
保单已生效 -
保费已支付 -
理赔申请已提交 -
理赔案件已调查 -
理赔已结算(赔付或拒赔) -
保单已终止
统一语言是关键:
从核心领域事件中,提炼出统一语言。
-
投保单(Proposal): 客户的投保申请,尚未成为正式合同。
-
保单(Policy): 生效的保险合同,是核心实体。
-
被保险人(Insured Person): 保障对象,注意与投保人(Policyholder)可能不同。
-
核保(Underwriting): 风险评估过程,决定是否承保及以什么条件承保。
-
理赔(Claim): 保险事故发生后,客户提出的赔付请求。
-
保险责任(Coverage): 保单提供的具体保障范围,如住院医疗、门诊手术等。一个保单可以有多个责任。
2. 划定限界上下文(Bounded Context)
通过分析,我们识别出几个核心的上下文边界:
-
产品上下文(Product Context) : 负责定义和配置保险产品。如"安康百万医疗险2024版"。包含责任定义、保费计算规则、等待期、免赔额等。它关心"卖什么"。
-
投保与保单上下文(Proposal & Policy Context) : 核心领域。处理从投保、核保到保单生效、批改的全生命周期。它关心"合同是什么状态"。
-
理赔上下文(Claim Context) : 处理理赔申请、调查、理算和结算。它关心"出了事怎么办"。
-
支付上下文(Payment Context) : 负责保费的收取和赔款的支付,与外部支付网关交互。它关心"钱的进出"。
3. 上下文映射(Context Map)
-
产品上下文 -> 投保与保单上下文: 遵奉者关系。投保上下文需要严格遵循产品上下文定义的规则,产品上下文是权威来源。
-
投保与保单上下文 -> 理赔上下文: 客户-供应商关系。理赔上下文需要从保单上下文获取保单信息、有效责任来核实理赔资格。
-
投保与保单上下文/理赔上下文 -> 支付上下文: 客户-供应商关系。它们发起支付和赔付指令。
第二步:战术设计 - 构建解决方案模型(领域模型)
我们聚焦于最复杂的 投保与保单上下文 和 理赔上下文。
1. 在 投保与保单上下文 内部:
这是整个系统的核心。
-
实体(Entity)
-
Proposal(投保单): 有proposalId,状态如待核保。 -
Policy(保单): 聚合根,有policyNo,状态如保障中、已终止。 -
InsuredPerson(被保险人): 健康信息、职业等信息在此。
-
-
值对象(Value Object)
-
Address(地址) -
CoverageDetail(责任详情): 从产品上下文复制过来,包括责任类型、保额、免赔额等。这是一个重要的防腐措施,保证了即使原始产品停售,已生效保单的保障内容也不变。 -
Period(期限): 封装起保日期和终保日期。
-
-
聚合根(Aggregate Root)
Policy是明确的聚合根。所有对保单的修改(如地址变更、受益人修改)都必须通过Policy对象进行。
-
领域服务(Domain Service)
-
UnderwritingService(核保服务): 核保是核心领域逻辑,它非常复杂。-
输入:
Proposal(包含健康告知)、UnderwritingRules(核保规则,来自产品上下文) -
输出:
UnderwritingResult(核保结果:标准体、加费、除外责任、拒保)
-
-
-
领域事件(Domain Event)
-
PolicyIssuedEvent(保单已签发事件) -
PolicyLapsedEvent(保单已失效事件)
-
java
// 聚合根 - 保单
public class Policy {
private PolicyId id;
private String policyNo;
private List<CoverageDetail> coverages;
private Period validPeriod;
private PolicyStatus status;
// 批改:增加保额
public void increaseSumAssured(CoverageType type, Money newAmount) {
// 1. 业务规则:只有在保障中的保单才能批改
if (this.status != PolicyStatus.IN_FORCE) {
throw new IllegalPolicyOperationException("Only in-force policy can be endorsed.");
}
// 2. 找到对应责任并修改
CoverageDetail coverage = findCoverageByType(type);
coverage.setSumAssured(newAmount);
// 3. 可能触发领域事件,如需要重新核保等
}
}
// 领域服务 - 核保
@Service
public class UnderwritingService {
public UnderwritingResult underwrite(Proposal proposal, UnderwritingRules rules) {
// 1. 年龄规则
if (!rules.isAgeEligible(proposal.getInsuredPerson().getAge())) {
return UnderwritingResult.rejected("年龄不符合要求");
}
// 2. 健康告知规则(复杂逻辑)
for (HealthDeclaration declaration : proposal.getHealthDeclarations()) {
UnderwritingRule rule = rules.findRule(declaration.getCondition());
if (rule != null) {
// 根据规则进行评估,可能返回加费、除外等结果
UnderwritingDecision decision = rule.evaluate(declaration);
if (decision.isRejection()) {
return UnderwritingResult.rejected(decision.getReason());
}
// 累积加费或除外责任...
}
}
// 3. 组合所有核保决定,生成最终结果
return UnderwritingResult.approvedWithModifications(... /* 加费比例、除外责任列表 */);
}
}
2. 在 理赔上下文 内部:
-
聚合根(Aggregate Root)
Claim(理赔案件): 有claimId,是整个理赔聚合的根。
-
实体(Entity)
-
ClaimItem(理赔项): 一个理赔案件可能对应多个医疗费用项。 -
MedicalRecord(医疗记录): 值对象,由客户提供。
-
-
值对象(Value Object)
-
Diagnosis(诊断) -
Treatment(治疗方式)
-
-
领域服务(Domain Service)
-
ClaimAssessmentService(理赔理算服务): 核心领域逻辑,计算赔付金额。-
输入:
Claim、PolicyCoverages(从保单上下文获取) -
输出:
AssessmentResult(赔付金额、拒赔原因)
-
-
-
规约模式(Specification)
ClaimEligibilitySpecification(理赔资格规约): 封装"一个理赔案件是否有效"的复杂业务规则。
java
// 聚合根 - 理赔案件
public class Claim {
private ClaimId id;
private PolicyId policyId; // 关联的保单ID
private Diagnosis diagnosis;
private List<ClaimItem> items;
private ClaimStatus status;
private Money assessedAmount;
// 提交理赔申请
public void submit() {
// 验证基本数据...
this.status = ClaimStatus.SUBMITTED;
}
// 核心业务:理算
public void assess(ClaimAssessmentService service, PolicyCoverages coverages) {
// 委托给领域服务进行复杂的理算逻辑
AssessmentResult result = service.assess(this, coverages);
this.assessedAmount = result.getAmount();
this.status = result.isApproved() ? ClaimStatus.APPROVED : ClaimStatus.REJECTED;
// 记录拒赔原因等...
}
}
// 领域服务 - 理赔理算
@Service
public class ClaimAssessmentService {
public AssessmentResult assess(Claim claim, PolicyCoverages coverages) {
Money totalPayable = Money.zero();
// 1. 检查免责期、等待期等全局规则
if (!coverages.isWithinCoveragePeriod(claim.getAccidentDate())) {
return AssessmentResult.rejected("事故日期不在保障期内");
}
// 2. 遍历每一个理赔项,计算赔付
for (ClaimItem item : claim.getItems()) {
// 找到对应的保险责任
CoverageDetail coverage = coverages.findCoverageForItem(item);
// 应用责任规则:免赔额、赔付比例、年度保额上限等
Money payableForItem = coverage.calculatePayable(
item.getAmount(),
coverages.getAccumulatedPaidAmount() // 已累计赔付金额
);
totalPayable = totalPayable.add(payableForItem);
}
return AssessmentResult.approved(totalPayable);
}
}
第三步:架构与集成
-
微服务架构: 每个限界上下文对应一个微服务(Product Service, Policy Service, Claim Service, Payment Service)。
-
数据持久化: 每个服务拥有独立数据库。Policy Service 存储保单副本,Claim Service 存储理赔数据。
-
通信方式:
-
同步调用(API) : Claim Service 需要查询保单信息时,通过一个防腐层调用 Policy Service 的API,将保单信息转换为理赔上下文内部的模型,防止污染。
-
异步事件(Message Queue) : 当 Policy Service 中保单失效时,发布
PolicyLapsedEvent。Claim Service 订阅该事件,并据此拒绝此后该保单的理赔申请。
-
总结:DDD在健康保险中的价值
-
应对极致复杂度: 将核保、理算等极其复杂的金融 和 医学逻辑 封装在 领域服务和聚合根中,代码清晰表达了业务规则。
-
精准的业务边界: 产品、保单、理赔等核心概念被严格分离,团队可以专注于自己的领域,并行开发。新产品上线(产品上下文修改)不会影响已有保单的运行。
-
高度的可扩展性 : 当需要增加"直付服务"(医院直接与保险公司结算)时,可以创建一个新的
直付上下文,它订阅ClaimApprovedEvent,并与Policy Service和医院系统交互,对现有核心系统冲击最小。 -
清晰的审计与合规: 每一个状态变更(如核保通过、理赔赔付)都通过聚合根的方法完成,所有业务规则和校验都集中在此,易于追溯和审计,满足 金融监管要求。
通过DDD,我们成功地将一个混乱的"大泥球"保险系统,重构为一个边界清晰、业务逻辑高度内聚、能够快速响应市场变化的现代化核心系统。
追问环节
1. DDD分层架构设计
DDD推荐使用分层架构设计,在健康保险的业务场景下, 上面给到的第二步战术设计强调的是领域层的设计,那么在健康保险的业务场景下, 用户接口层和应用层应该对应哪些内容?
标准的DDD分层架构从外到内是:
-
**用户接口层:**处理用户交互(包括Web、API、CLI等),展示信息,并解析和传递用户指令。
-
应用层
-
领域层
-
基础设施层
一、用户接口层
在健康保险的Web API场景下,这一层主要由Controller 和DTO构成。
1. 在 投保与保单上下文 中:
1. 投保相关接口:
ProposalController :提交投保申请、查询投保单详
2. 保单管理相关接口:
PolicyController:查询保单详情、提交保单批改申请、申请保单失
java
// 用户接口层 - Controller
@RestController
@RequestMapping("/api/proposals")
public class ProposalController {
@Autowired
private ProposalApplicationService proposalAppService;
@PostMapping
public ResponseEntity<ProposalSubmissionResponse> submitProposal(@RequestBody SubmitProposalRequest request) {
// 1. 将用户请求的DTO转换为应用层能理解的Command
SubmitProposalCommand command = new SubmitProposalCommand(
request.getApplicantInfo(),
request.getInsuredInfo(),
request.getHealthDeclarations(),
request.getSelectedPlanId()
);
// 2. 调用应用服务
String proposalId = proposalAppService.submitProposal(command);
// 3. 将结果包装成DTO返回给前端
ProposalSubmissionResponse response = new ProposalSubmissionResponse(proposalId, "SUBMITTED");
return ResponseEntity.ok(response);
}
}
// 用户接口层 - DTO (Input)
public class SubmitProposalRequest {
private ApplicantInfoDto applicantInfo;
private InsuredInfoDto insuredInfo;
private List<HealthDeclarationDto> healthDeclarations;
private String selectedPlanId;
// ... getters and setters
}
// 用户接口层 - DTO (Output)
public class ProposalSubmissionResponse {
private String proposalId;
private String status;
// ... constructor and getters
}
二、应用层
核心职责:协调任务,编排领域对象 来完成一个完整的用户用例。它不包含任何核心业务逻辑,而是"指挥家"。
-
它接收来自用户接口层的"命令"或"查询"对象。
-
它依赖领域层(聚合根、领域服务、仓储接口)和基础设施层(如其他微服务调用)来完成任务。
-
它负责事务控制、权限校验(初步的、用例级的)、发布领域事件等。
1. 在 投保与保单上下文 中:
1. 投保应用服务:
ProposalApplicationService
java
// 应用层 - 应用服务
@Service
@Transactional
public class ProposalApplicationService {
@Autowired
private ProposalRepository proposalRepository;
@Autowired
private UnderwritingService underwritingService; // 领域服务
@Autowired
private ProductService productService; // 防腐层接口,用于获取产品信息
@Autowired
private DomainEventPublisher eventPublisher;
public String submitProposal(SubmitProposalCommand command) {
// 【步骤1:获取外部信息(通过防腐层)】
ProductInfo productInfo = productService.getProductInfo(command.getSelectedPlanId());
// 【步骤2:调用领域层创建领域对象】
// 使用Factory模式创建投保单聚合根
Proposal proposal = ProposalFactory.createProposal(
command.getApplicantInfo(),
command.getInsuredInfo(),
command.getHealthDeclarations(),
productInfo
);
// 【步骤3:调用领域服务执行核心业务逻辑】
UnderwritingResult result = underwritingService.underwrite(proposal, productInfo.getUnderwritingRules());
proposal.setUnderwritingResult(result);
// 【步骤4:持久化领域对象】
proposalRepository.save(proposal);
// 【步骤5:发布领域事件(如果需要)】
if (result.isApproved()) {
eventPublisher.publish(new ProposalUnderwrittenEvent(proposal.getId(), ...));
}
// 【步骤6:返回结果(通常是ID或状态)】
return proposal.getId().toString();
}
}
2. 在 理赔上下文 中:
2. 理赔应用服务:
ClaimApplicationService
java
@Service
@Transactional
public class ClaimApplicationService {
@Autowired
private ClaimRepository claimRepository;
@Autowired
private PolicyService policyService; // 防腐层接口,用于从保单上下文获取保单详情
@Autowired
private ClaimAssessmentService assessmentService; // 领域服务
public String submitClaim(SubmitClaimCommand command) {
// 1. 通过防腐层,从"保单上下文"获取有效的保单和责任信息
PolicyCoverages policyCoverages = policyService.getValidPolicyCoverages(command.getPolicyNo());
// 2. 创建理赔聚合根
Claim claim = new Claim(
ClaimId.generate(),
command.getPolicyNo(),
command.getAccidentDetails(),
command.getMedicalRecords()
);
// 3. 调用领域服务进行理算
claim.assess(assessmentService, policyCoverages);
// 4. 持久化
claimRepository.save(claim);
// 5. 如果理赔通过,可能触发支付指令(通过发布事件或调用支付应用服务)
if (claim.getStatus() == ClaimStatus.APPROVED) {
// 例如:eventPublisher.publish(new ClaimApprovedEvent(claim.getId(), claim.getAssessedAmount()));
}
return claim.getId().toString();
}
}
3. 查询应用服务 - 专门用于CQRS中的Query:
对于复杂的查询(如保单列表、理赔进度看板),应用层可以绕过领域层,直接调用基础设施层的查询接口(如MyBatis Mapper、数据库客户端)返回DTO。
总结:健康保险场景下的分层职责
| 分层 | 在健康保险场景下的核心内容 | 是否包含业务逻辑 |
|---|---|---|
| 用户接口层 | - ProposalController, PolicyController, ClaimController - 各种Request/Response DTO(如SubmitProposalRequest, PolicyDetailsResponse) |
否 |
| 应用层 | - ProposalApplicationService, ClaimApplicationService(处理写操作) - PolicyQueryService(处理读操作) - SubmitProposalCommand, SubmitClaimCommand(应用层内部指令) |
否 ,它包含工作流逻辑 ,但不包含核心业务规则 |
| 领域层 | - Policy, Proposal, Claim(聚合根) - UnderwritingService, ClaimAssessmentService(领域服务) - CoverageDetail, Money(值对象) - PolicyIssuedEvent(领域事件) |
是 ,核心业务逻辑所在地 |
| 基础设施层 | - PolicyRepositoryImpl, ClaimRepositoryImpl(持久化实现) - ProductServiceClient(调用产品上下文的防腐层实现) - DomainEventPublisherImpl(事件发布实现) - PolicyQueryRepositoryImpl(查询数据库实现) |
否 |
- 领域层可以专注于复杂的核保、理算规则;
- 应用层 则像一个导演,协调
仓储获取数据,调用领域服务进行核保,再通过防腐层获取外部产品信息,最后 命令仓储保存数据 并发布事件。 - 用户接口层则只关心如何接收和展示数据。这样确保了系统的关注点分离,架构清晰,易于维护和演化。
2. 存储数据应该哪一层来调用
问题:基础设施层存储数据, 是应用层去调用基础设施层存储数据,还是领域层调用基础设施层存储数据?
正确答案是:由应用层调用基础设施层来存储数据,而不是领域层。
领域层应该对如何存储数据一无所知,它只关心业务逻辑和状态变化。
为什么必须由应用层调用?关键区别对比
| 方面 | 应用层调用存储(正确) | 领域层调用存储(错误) |
|---|---|---|
| 架构纯洁性 | 领域层保持纯洁,不依赖任何技术实现 | 领域层被基础设施细节污染,变成"贫血模型"的反面------"充血过度" |
| 事务控制 | 应用层可以方便地使用@Transactional管理 整个用例的事务 |
领域层中的保存操作难以统一管理事务边界,容易导致部分更新 |
| 性能优化 | 应用层可以批量加载和保存多个聚合,优化数据库交互 | 领域逻辑中的分散保存会导致多次数据库往返,性能低下 |
| 测试便利性 | 领域层可以完全脱离仓储进行单元测试,测试纯粹的业务逻辑 | 测试领域逻辑必须mock仓储,测试变得复杂且缓慢 |
| 用例完整性 | 应用层确保一个用例中的 所有领域操作和存储操作作为一个完整单元 | 存储操作分散在领域各处,难以保证业务操作的原子性 |
| 依赖方向 | 依赖方向正确:领域层 ← 应用层 → 基础设施层 | 依赖方向错误:领域层 → 基础设施层,违反依赖倒置原则 |