分治的边界

分治的边界:从 SRP 到限界上下文,再到 Rust 中真正可治理的设计

分治并不天然正确。

很多时候,我们一提到“拆分”,就会本能地觉得这是在优化架构、降低复杂度。但事实恰恰相反:分治最大的风险,不是没有拆,而是拆错了边界。一个错误的拆分,会把原本内聚的整体强行打散,结果不是降低复杂度,而是引入更多耦合、更多协调成本、更多隐式依赖,最终让系统变得更难理解、更难修改、更难测试。

所以,真正重要的问题从来不是“要不要分”,而是:

应该按什么边界来分?

我个人目前比较认可,可以从两个层级来理解这个问题:

  1. 代码层级:单一职责原则(SRP)
  2. 系统层级:限界上下文(Bounded Context)

这两个概念看起来属于不同尺度,但它们背后的思想其实非常一致:分,不是为了把东西切碎;分,是为了把复杂度约束在局部。


一、代码层级的边界:SRP 不是“一个类只做一件事”,而是“只有一个变化理由”

在代码层面,我们面对的最大问题是什么?

不是“代码长不长”,也不是“函数优不优雅”,而是变更

一个软件生命周期里最大的成本,往往不是第一次把功能写出来,而是后续不断地响应需求变化、修 bug、扩展逻辑、兼容新场景。维护成本的本质,就是应对变化的成本。

Robert C. Martin 对 SRP 的经典表述是:

A class should have only one reason to change.

也就是:

一个类,应该只有一个变化的理由。

很多人第一次接触 SRP 时,会把它理解成“一个类只做一件事”。这句话不能说错,但太模糊。真正更有操作性的判断方式其实是:

如果两个逻辑会因为不同的人、不同部门、不同规则变化而分别修改,那它们就不该耦合在一起。

举个经典例子。假设我们有一个 Employee 类型,它同时负责:

  • 薪酬计算
  • 数据库存储
  • 报表生成

那么这三个职责其实分别对应三种完全不同的变化来源:

  • 财务规则变了,薪酬计算要改
  • DBA 调整表结构了,持久化逻辑要改
  • HR 改报表格式了,报表生成要改

如果它们被塞进同一个类里,会发生什么?

  • 改薪酬逻辑,可能误伤数据库逻辑
  • 改存储逻辑,可能影响报表
  • 一个类被多个团队从不同方向同时修改
  • 测试范围越来越大,改动影响面越来越不清晰

这就是复杂度滋生的起点。

Rust 风格的 SRP 拆分

在 Rust 里,我们通常不会把所有行为都塞进一个“大对象”里,而是更倾向于把能力拆成清晰的结构体和 trait。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#[derive(Debug, Clone)]
pub struct Employee {
pub id: u64,
pub name: String,
pub base_salary: i64,
pub bonus: i64,
}

pub struct PayrollCalculator;

impl PayrollCalculator {
pub fn calculate_salary(&self, employee: &Employee) -> i64 {
employee.base_salary + employee.bonus
}
}

pub trait EmployeeRepository {
fn save(&self, employee: &Employee) -> Result<(), String>;
}

pub struct SqlEmployeeRepository;

impl EmployeeRepository for SqlEmployeeRepository {
fn save(&self, employee: &Employee) -> Result<(), String> {
println!("save employee {} to database", employee.id);
Ok(())
}
}

pub struct EmployeeReporter;

impl EmployeeReporter {
pub fn generate_report(&self, employee: &Employee) -> String {
format!(
"Employee Report => id: {}, name: {}, base_salary: {}, bonus: {}",
employee.id, employee.name, employee.base_salary, employee.bonus
)
}
}

这里的拆分重点不在于“代码看起来更工整”,而在于:

  • PayrollCalculator 只会因为薪酬规则变化而变化
  • SqlEmployeeRepository 只会因为存储策略变化而变化
  • EmployeeReporter 只会因为报表格式变化而变化

也就是说,SRP 的边界,不是功能名词的边界,而是变化来源的边界。


二、系统层级的边界:限界上下文解决的不是代码问题,而是语义问题

到了系统层面,尤其是大型业务系统里,复杂度的来源就不只是“代码如何组织”了。

这时候更大的问题是:

同一个词,在不同业务里,到底是不是同一个东西?

比如“客户(Customer)”这个词:

  • 在销售团队眼里,客户是潜在购买者
  • 在客服团队眼里,客户是有服务关系的注册用户
  • 在财务团队眼里,客户是有付款行为的法律实体

如果我们强行建立一个“统一 Customer 大模型”,试图让一个模型覆盖所有场景,结果通常会是:

  • 字段越来越多
  • 各种条件分支越来越多
  • 不同团队对模型含义理解不一致
  • 每个场景都觉得这个模型“不太对”

这时候问题已经不是代码优不优雅,而是语义冲突

DDD 里的限界上下文(Bounded Context)就是为了解决这个问题。

限界上下文的核心不是“拆服务”,而是“定义语义边界”

在不同上下文里,可以允许同一个概念拥有不同模型。

比如:

  • SalesCustomer
  • SupportCustomer
  • BillingCustomer

它们名字都带着“Customer”,但语义不同、关注点不同、规则不同,所以本来就不该硬揉成一个模型。

如果用 Rust 表达这个思想,可以很自然地把它们设计成不同类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#[derive(Debug)]
pub struct SalesCustomer {
pub lead_id: u64,
pub name: String,
pub contact: String,
pub intention_level: u8,
}

#[derive(Debug)]
pub struct SupportCustomer {
pub user_id: u64,
pub nickname: String,
pub active_ticket_count: u32,
}

#[derive(Debug)]
pub struct BillingCustomer {
pub legal_entity_id: u64,
pub company_name: String,
pub tax_number: String,
pub paid_amount: i64,
}

这三个结构体看起来“重复”,但这种重复并不是坏事。

因为它们表达的是:

  • 销售上下文里的客户
  • 客服上下文里的客户
  • 财务上下文里的客户

限界上下文的价值,不是复用模型,而是避免错误复用模型。

这是很多系统设计里最容易被忽视的一点。


三、真正的分治,不是把 500 行函数拆成 5 个 100 行函数

我过去在游戏业务里接触过一种非常典型的“巨型结算接口”。

这个接口要处理很多事情:

  • 多种游戏模式的结算逻辑
  • 成绩计算
  • 奖励发放
  • 历史荣誉更新
  • 师徒系统加成
  • 任务系统进度推进
  • 活动奖励发放
  • 而且部分奖励必须同步返回给客户端,不能纯异步

如果你把这些逻辑全部塞在一个接口里,代码通常会长成这样:

1
2
3
4
5
6
7
8
9
10
11
pub fn settle(req: SettleRequest) -> Result<SettleResponse, String> {
// 1. 参数解析
// 2. 成绩格式化
// 3. 按模式分支
// 4. 计算奖励
// 5. 更新任务
// 6. 发布事件
// 7. 持久化
// 8. 构建响应
todo!()
}

这种代码的问题,不只是“长”,而是它混杂了多个抽象层级:

  • HTTP / 接口层职责
  • 业务编排职责
  • 业务执行职责
  • 副作用管理职责
  • 响应构建职责

很多人会进一步做一个“表面拆分”:

1
2
3
4
5
6
7
8
pub fn process_game_mode_1(ctx: &SettlementContext) -> Result<SettlementResult, String> {
let r1 = step1(ctx)?;
let r2 = step2(ctx, r1)?;
let r3 = step3(ctx, r2)?;
let r4 = step4(ctx, r3)?;
let r5 = step5(ctx, r4)?;
Ok(r5)
}

这看起来好像变好了,但实际上并没有真正治理复杂度。

因为这些 step1/step2/step3 往往只是把原来的大段代码搬到了不同函数里,而没有改变依赖关系和职责结构。它依然存在几个根本问题:

  • 不能独立理解
  • 不能独立修改
  • 不能独立测试
  • 不能灵活组合

换句话说,这种拆分实现了“分”,但没有实现“治”。


四、什么叫“真正的治理”?

我现在越来越倾向于用下面四个维度来判断一个拆分到底有没有价值:

  1. 可独立理解
  2. 可独立修改
  3. 可独立验证
  4. 可灵活组合

好的架构不是消灭复杂度,因为业务本来就复杂。

好的架构只是做到一件事:

让复杂度被约束在局部,每个局部都能被人稳定地理解、修改、测试和复用。

下面用 Rust 的方式来表达这个思路。


五、用 Rust 实现一个可治理的结算 Pipeline

1. 先定义上下文与结果

我们先把流程的输入和输出抽出来,避免每个处理器都各自操作一堆零散参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
use std::collections::HashMap;

#[derive(Debug, Clone, Copy)]
pub enum GameMode {
Mode1,
Mode2,
}

#[derive(Debug, Clone)]
pub struct Player {
pub id: u64,
pub base_score: i32,
}

#[derive(Debug, Clone)]
pub struct SettleRequest {
pub mode: GameMode,
pub players: Vec<Player>,
}

#[derive(Debug, Clone)]
pub struct SettlementContext {
pub mode: GameMode,
pub players: Vec<Player>,
}

impl SettlementContext {
pub fn from_request(req: SettleRequest) -> Result<Self, String> {
if req.players.is_empty() {
return Err("players cannot be empty".to_string());
}

Ok(Self {
mode: req.mode,
players: req.players,
})
}
}

#[derive(Debug, Clone, Default)]
pub struct Reward {
pub coins: i32,
}

#[derive(Debug, Clone)]
pub struct PlayerReward {
pub player_id: u64,
pub base_reward: Reward,
pub total_reward: Reward,
}

#[derive(Debug, Default)]
pub struct SettlementResult {
pub player_rewards: HashMap<u64, PlayerReward>,
pub events: Vec<String>,
}

#[derive(Debug)]
pub struct SettleResponse {
pub rewards: HashMap<u64, PlayerReward>,
pub events: Vec<String>,
}

impl From<SettlementResult> for SettleResponse {
fn from(result: SettlementResult) -> Self {
Self {
rewards: result.player_rewards,
events: result.events,
}
}
}

这一层的意义在于:

  • SettlementContext 表达流程输入
  • SettlementResult 表达流程累积产物
  • SettleResponse 表达最终对外返回结果

这样,业务处理单元之间共享的是明确的流程模型,而不是散乱参数。


2. 定义 Processor:让每个处理单元只关心一个职责

接下来定义统一的处理器接口。

1
2
3
4
5
6
7
pub trait Processor {
fn process(
&self,
ctx: &SettlementContext,
result: &mut SettlementResult,
) -> Result<(), String>;
}

这个 trait 很关键。它表达的是:

  • 处理器读取上下文
  • 处理器修改结果
  • 处理器只负责自己那一段逻辑

接着我们定义计分职责。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
pub trait ScoreCalculator {
fn calculate(&self, player: &Player, ctx: &SettlementContext) -> i32;
fn convert_to_reward(&self, score: i32) -> Reward;
}

pub struct DefaultScoreCalculator;

impl ScoreCalculator for DefaultScoreCalculator {
fn calculate(&self, player: &Player, _ctx: &SettlementContext) -> i32 {
player.base_score
}

fn convert_to_reward(&self, score: i32) -> Reward {
Reward { coins: score * 10 }
}
}

pub struct ScoreCalculationProcessor<C: ScoreCalculator> {
pub calculator: C,
}

impl<C: ScoreCalculator> Processor for ScoreCalculationProcessor<C> {
fn process(
&self,
ctx: &SettlementContext,
result: &mut SettlementResult,
) -> Result<(), String> {
for player in &ctx.players {
let score = self.calculator.calculate(player, ctx);
let reward = self.calculator.convert_to_reward(score);

result.player_rewards.insert(
player.id,
PlayerReward {
player_id: player.id,
base_reward: reward.clone(),
total_reward: reward,
},
);
}

Ok(())
}
}

这段代码体现的是认知治理

当你阅读 ScoreCalculationProcessor 时,不需要理解:

  • HTTP 是怎么进来的
  • 数据库怎么存
  • 活动系统怎么发奖
  • 师徒系统怎么计算

你只需要理解一件事:

输入玩家分数,输出基础奖励。

这就是“可独立理解”。


3. 再把师徒加成拆成独立单元

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
pub trait MasterApprenticeService {
fn bonus_rate(&self, player_id: u64) -> f32;
}

pub struct DefaultMasterApprenticeService;

impl MasterApprenticeService for DefaultMasterApprenticeService {
fn bonus_rate(&self, player_id: u64) -> f32 {
if player_id % 2 == 0 { 0.2 } else { 0.0 }
}
}

pub struct MasterApprenticeProcessor<S: MasterApprenticeService> {
pub service: S,
}

impl<S: MasterApprenticeService> Processor for MasterApprenticeProcessor<S> {
fn process(
&self,
_ctx: &SettlementContext,
result: &mut SettlementResult,
) -> Result<(), String> {
for reward in result.player_rewards.values_mut() {
let rate = self.service.bonus_rate(reward.player_id);
let bonus = (reward.base_reward.coins as f32 * rate) as i32;
reward.total_reward.coins += bonus;
}

Ok(())
}
}

这段代码体现的是演化治理

如果未来产品说:

  • 师徒加成从 20% 改成 15%
  • 某些模式不参与师徒加成
  • 某类玩家有特殊规则

那么你修改的边界非常明确:

  • MasterApprenticeService
  • 或改 MasterApprenticeProcessor

而不需要在一大段结算总流程里艰难定位一段逻辑。

这就是“可独立修改”。


4. Pipeline 负责组合,而不是负责业务细节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pub struct SettlementPipeline {
processors: Vec<Box<dyn Processor>>,
}

impl SettlementPipeline {
pub fn new(processors: Vec<Box<dyn Processor>>) -> Self {
Self { processors }
}

pub fn execute(&self, ctx: &SettlementContext) -> Result<SettleResponse, String> {
let mut result = SettlementResult::default();

for processor in &self.processors {
processor.process(ctx, &mut result)?;
}

Ok(result.into())
}
}

SettlementPipeline 的职责非常纯粹:

  • 维护处理顺序
  • 驱动流程执行
  • 聚合最终结果

它并不关心每个处理器内部细节。

这种设计带来的好处是,流程控制和业务实现分离了。编排归编排,规则归规则。

这就是“组合治理”的基础。


5. Factory 决定不同模式如何装配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub struct SettlementPipelineFactory;

impl SettlementPipelineFactory {
pub fn create_mode1_pipeline(&self) -> SettlementPipeline {
SettlementPipeline::new(vec![
Box::new(ScoreCalculationProcessor {
calculator: DefaultScoreCalculator,
}),
Box::new(MasterApprenticeProcessor {
service: DefaultMasterApprenticeService,
}),
])
}

pub fn create_mode2_pipeline(&self) -> SettlementPipeline {
SettlementPipeline::new(vec![
Box::new(ScoreCalculationProcessor {
calculator: DefaultScoreCalculator,
}),
])
}
}

这里体现的是很关键的一点:

新增模式,不一定意味着修改原有主流程;更理想的方式是新增一种组合。

比如:

  • Mode1 有基础计分 + 师徒加成
  • Mode2 只有基础计分
  • Mode3 可以是基础计分 + 活动奖励 + 排行榜结算

你做的是装配不同 pipeline,而不是不断在主流程里堆 if else


6. 最后让 API 只负责入口职责

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub struct Api {
pipeline_factory: SettlementPipelineFactory,
}

impl Api {
pub fn new() -> Self {
Self {
pipeline_factory: SettlementPipelineFactory,
}
}

pub fn settle(&self, req: SettleRequest) -> Result<SettleResponse, String> {
let ctx = SettlementContext::from_request(req)?;

let pipeline = match ctx.mode {
GameMode::Mode1 => self.pipeline_factory.create_mode1_pipeline(),
GameMode::Mode2 => self.pipeline_factory.create_mode2_pipeline(),
};

pipeline.execute(&ctx)
}
}

这一层就非常清晰了:

  • 请求进来
  • 转成上下文
  • 根据模式选择流程
  • 执行流程
  • 返回结果

此时 Api::settle 已经不是一个“巨石方法”,而是一个真正的入口编排器。


六、为什么这种拆分才算“真正的分治”?

因为它不只是把代码切开了,而是让每一部分都具备独立治理能力。

1. 可独立理解

你看 ScoreCalculationProcessor,知道它只负责算基础奖励。

你看 MasterApprenticeProcessor,知道它只负责师徒加成。

你不需要读完整个结算流程,才能理解局部逻辑。

2. 可独立修改

改师徒系统,不需要冒险碰计分逻辑。

改活动奖励,不需要去翻数据库落库逻辑。

每种变化都有明确落点。

3. 可独立验证

测试也会变得非常自然。

比如测试计分逻辑时,只关心输入分数和输出奖励:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn score_processor_should_convert_score_to_reward() {
let ctx = SettlementContext {
mode: GameMode::Mode1,
players: vec![Player { id: 1, base_score: 10 }],
};

let processor = ScoreCalculationProcessor {
calculator: DefaultScoreCalculator,
};

let mut result = SettlementResult::default();
processor.process(&ctx, &mut result).unwrap();

let reward = result.player_rewards.get(&1).unwrap();
assert_eq!(reward.base_reward.coins, 100);
assert_eq!(reward.total_reward.coins, 100);
}

#[test]
fn master_apprentice_processor_should_apply_bonus() {
let mut result = SettlementResult::default();
result.player_rewards.insert(
2,
PlayerReward {
player_id: 2,
base_reward: Reward { coins: 100 },
total_reward: Reward { coins: 100 },
},
);

let processor = MasterApprenticeProcessor {
service: DefaultMasterApprenticeService,
};

let ctx = SettlementContext {
mode: GameMode::Mode1,
players: vec![],
};

processor.process(&ctx, &mut result).unwrap();

let reward = result.player_rewards.get(&2).unwrap();
assert_eq!(reward.total_reward.coins, 120);
}
}

你会发现,这种测试的特点是:

  • 输入很小
  • 依赖很少
  • 断言很精确
  • 执行很快

这就是“可独立验证”。

4. 可灵活组合

新增一个模式时,你大概率只需要:

  • 新增一个 Processor
  • 或新增一个新的 Pipeline 装配方案

而不是冲进那个几百行主函数里再追加一个大分支。

这就是“可灵活组合”。


七、SRP 和限界上下文,本质上解决的是同一个问题

看到这里其实会发现,SRP 和限界上下文虽然一个偏代码、一个偏系统,但它们都在回答同一个根本问题:

边界该怎么画,才能让复杂度停留在局部,而不向全局扩散?

它们的区别只是作用层级不同。

SRP 关注的是:

  • 代码里的变化理由
  • 模块职责是否混杂
  • 修改边界是否清晰

限界上下文关注的是:

  • 业务语义是否一致
  • 模型是否被错误共享
  • 团队协作边界是否清晰

所以可以这样理解:

  • 在代码层级,SRP 是按“变化理由”划边界
  • 在系统层级,Bounded Context 是按“语义一致性”划边界

两者本质上都是在做一件事:

把复杂系统切成高内聚、低耦合、局部可治理的单元。


八、结语:真正好的拆分,不是更碎,而是更稳

我现在越来越觉得,架构设计里最重要的能力,不是“会不会拆”,而是“知不知道什么不该拆”。

因为错误的拆分,往往比不拆更糟。

  • 把本应内聚的逻辑拆散,会带来高耦合
  • 把强语义关联的概念硬拆开,会带来额外通信成本
  • 把本该独立演化的职责揉在一起,会让每次变更都像拆雷

所以,真正有价值的分治,不是为了让代码看起来更模块化,也不是为了迎合某种架构风格,而是为了让复杂度在局部被稳定控制住。

如果一个拆分最终没有带来这些收益:

  • 更容易理解
  • 更容易修改
  • 更容易测试
  • 更容易组合

那它很可能只是“看起来分了”,其实并没有真正治理复杂度。

而我理解中的“真正的分治”,恰恰就在这里:

不是把大问题切碎,而是把每一块都切成可以独立治理的单元。