模块边界设计:让改动停在该停的地方
改动范围决定了系统的真实复杂度
做了几年的项目维护,我发现一个规律:系统复杂度增长后,真正让人头疼的问题往往不在某个函数写得差,而在模块之间的边界不清晰。一个看似简单的需求改动——比如「订单详情页加一个字段」——要穿过多个目录、多个服务、多个数据结构,改动的爆炸半径就会不断扩大。
我曾经参与过一个电商后台重构,最初只是想把「商品库存」逻辑从订单模块里拆出来独立管理。结果发现库存计算散落在订单服务、支付回调、仓储通知三个地方,涉及 7 个共享类型和 2 张数据库表。原本预计两周的改动,最后花了六周。
事后复盘时,我们意识到问题的根源不是代码质量差,而是模块边界从一开始就没有设计好。库存、订单、支付这三个职责在物理上被混在了一起,逻辑上也没有明确的契约来隔离它们。
这篇文章记录了我从那次经历中总结出的模块边界设计原则。核心目标只有一个:让改动停在该停的地方。
理论基础:为什么边界比代码更重要
高内聚、低耦合——老原则的新解读
「高内聚、低耦合」几乎每个开发者都听过,但真正理解它在实际项目中含义的人并不多。Robert C. Martin 在 Clean Architecture 中把它拆成了两条可操作的规则:
- SREP(稳定复用原则):被复用的模块应当是稳定的,修改它会影响所有依赖方。
- SDP(稳定依赖原则):依赖方向必须指向更稳定的模块。
这两条规则指向同一个结论:模块边界不是文件划分,而是变更影响的隔离墙。一个模块如果同时承担数据读取、权限判断、业务流程和 UI 展示,那么任何一个需求的变动都可能穿透它,边界就形同虚设。
依赖方向必须单向流动
Martin Fowler 在讨论分层架构时反复强调一个观点:依赖方向应当自上而下,上层依赖下层,下层不依赖上层。这在实际工程中意味着——
- 业务逻辑不应当直接 import 页面组件。
- 数据访问层不应当硬编码 API 路由路径。
- 通用工具函数不应当引入业务领域类型。
一旦出现反向依赖或循环依赖,模块之间的边界就失去了方向性。后续重构、替换和测试都会变得困难。
边界即契约
模块化设计的一个核心理念是:模块之间通过契约通信,而不是通过实现细节。契约可以是 TypeScript 类型、API Schema、事件协议或者 RPC 接口定义。调用方只需要知道契约,不需要知道对方内部怎么组织代码。
这种思想在微服务架构中被广泛实践(领域边界对应服务边界),在单体应用中同样适用——模块边界对应的不是部署单元,而是变更隔离单元。
案例一:订单服务里的「万能模块」
场景
某 B2B 电商平台的订单服务 order-service 最初只有三个文件:order.controller.ts、order.service.ts、order.repository.ts。随着业务增长,陆续加入了库存检查、价格计算、优惠券核销、物流状态同步等能力,全部堆在 order.service.ts 里。
到了后期,这个文件超过 2000 行,任何需求改动都要修改它,Code Review 经常需要五六个人一起看。
翻车
有一次产品经理要求调整「阶梯折扣」的计算规则。改动本身只涉及价格计算逻辑,但由于库存预占逻辑也依赖同一个 calculatePrice() 函数,改动意外导致库存扣减时使用了折扣前价格,线上出现了 200 多笔订单金额不一致的问题。
事后分析发现,calculatePrice() 同时被三个不同的调用方使用:订单创建、库存预占、退款计算。每个调用方对「价格」的理解不完全一样,但都依赖同一个函数和同一套逻辑。
修复
将 order.service.ts 按职责边界拆分成四个独立模块:
order-service/
├── order/ # 订单生命周期管理
├── pricing/ # 价格计算(折扣、优惠券、阶梯价)
├── inventory/ # 库存检查与预占
└── fulfillment/ # 物流状态同步
每个模块通过明确的接口通信,不再共享内部实现。
代码对比
改动前——职责混杂的 Service:
// ❌ 坏做法:一个 Service 承担所有职责
class OrderService {
// 价格计算
async calculatePrice(items: CartItem[]): Promise<PriceResult> {
const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0)
const discount = await this.getDiscount(subtotal)
// 同时做了库存检查(副作用)
await this.checkStock(items)
return { subtotal, discount, total: subtotal - discount }
}
// 创建订单——依赖上面所有逻辑
async createOrder(cartId: string) {
const items = await this.cartRepo.getItems(cartId)
const price = await this.calculatePrice(items)
// 库存预占直接耦合了价格计算结果
await this.reserveInventory(items, price.subtotal)
return this.orderRepo.create({ items, price })
}
// 库存、退款、物流全在这里
async reserveInventory(items, amount) { /* ... */ }
async processRefund(orderId, reason) { /* ... */ }
async syncLogistics(orderId) { /* ... */ }
}改动后——按职责拆分:
// ✅ 好做法:每个模块只负责一件事,通过契约通信
// pricing/pricing.service.ts — 只管价格计算
export class PricingService {
async calculate(items: CartItem[]): Promise<PriceResult> {
const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0)
const discount = await this.discountRepo.apply(subtotal, items)
return { subtotal, discount, total: subtotal - discount }
}
}
// inventory/inventory.service.ts — 只管库存
export class InventoryService {
async reserve(items: CartItem[]): Promise<ReservationResult> {
// 库存预占不需要知道价格,只关心数量
return this.stockRepo.batchReserve(
items.map(i => ({ skuId: i.skuId, qty: i.qty }))
)
}
}
// order/order.service.ts — 只编排流程,不实现细节
export class OrderService {
constructor(
private pricing: PricingService,
private inventory: InventoryService,
private orderRepo: OrderRepository
) {}
async createOrder(cartId: string) {
const items = await this.cartRepo.getItems(cartId)
// 先算价,再占库存——各自独立,互不影响
const price = await this.pricing.calculate(items)
const reservation = await this.inventory.reserve(items)
if (!reservation.success) {
throw new InsufficientStockError(reservation.shortages)
}
return this.orderRepo.create({ items, price })
}
}拆分后的关键变化:PricingService 和 InventoryService 各自独立,互不依赖。OrderService 只做流程编排,不包含业务计算逻辑。改动价格规则不会影响库存逻辑,反之亦然。
案例二:前端组件的依赖地狱
场景
一个中后台管理系统,首页、用户管理、订单管理、数据看板四个功能模块都放在 src/pages/ 下。为了复用,团队抽了一个 src/components/common/ 目录,把所有公共组件都扔进去。
半年后 common/ 目录里有 80 多个组件,任何一个改动都可能影响全局。有人为了「方便」直接在 common 组件里 import 了 src/pages/dashboard/ 里的图表工具函数——common 层反向依赖了业务页面层。
翻车
团队决定把用户管理模块迁移到新的微前端框架。迁移时发现 src/components/common/UserAvatar.tsx 依赖了 src/pages/user-management/utils/formatRole.ts。这意味着 common 组件依赖了某个具体业务页面,拆不动。
类似的反向依赖有十几处,迁移成本远超预期。
修复
建立严格的分层依赖规则:
核心规则:箭头方向就是依赖方向,永远单向向下,禁止反向引用。
代码对比
改动前——common 组件依赖业务页面:
// ❌ 坏做法:通用组件 import 了业务页面的工具函数
// src/components/common/UserAvatar.tsx
import { formatRole } from '@/pages/user-management/utils/formatRole'
export function UserAvatar({ user }: { user: User }) {
return (
<div className="avatar">
<img src={user.avatar} alt={user.name} />
{/* 这里用了业务页面的格式化逻辑 */}
<span>{formatRole(user.role)}</span>
</div>
)
}改动后——通过类型契约解耦:
// ✅ 好做法:通用组件只接受展示所需的数据,不关心来源
// src/components/common/UserAvatar.tsx
// 不再 import formatRole,而是接收已格式化好的数据
interface UserAvatarProps {
name: string
avatar: string
roleLabel: string // 调用方负责格式化
}
export function UserAvatar({ name, avatar, roleLabel }: UserAvatarProps) {
return (
<div className="avatar">
<img src={avatar} alt={name} />
<span>{roleLabel}</span>
</div>
)
}
// 调用方在 feature 层完成格式化
// src/features/user-management/components/UserList.tsx
import { formatRole } from '../utils/formatRole'
import { UserAvatar } from '@/components/common/UserAvatar'
function UserList({ users }: { users: User[] }) {
return users.map(user => (
<UserAvatar
key={user.id}
name={user.name}
avatar={user.avatar}
roleLabel={formatRole(user.role)}
/>
))
}通用组件只接收「展示所需的数据」,不关心数据怎么来的。格式化逻辑留在业务模块内部,通过 Props 传递结果而不是传递逻辑。这样 UserAvatar 在任何页面、任何框架下都可以直接使用。
案例三:微服务之间的循环依赖
场景
一个 SaaS 平台有三个微服务:用户服务(user-service)、订单服务(order-service)、通知服务(notification-service)。
最初设计时,用户服务需要知道用户的最近订单状态(展示在个人中心),订单服务需要调用用户服务获取用户信息(下单时校验),通知服务需要同时调用两个服务获取用户和订单信息。
翻车
半年后,user-service 和 order-service 之间形成了循环依赖:
user-service → order-service(查最近订单)
order-service → user-service(查用户信息)
这导致部署顺序无法确定:A 依赖 B,B 依赖 A,任何一个服务先发布都可能在启动阶段调用到旧版本的对方。CI 流水线经常因为服务间调用超时失败,团队不得不引入复杂的启动顺序编排。
修复
用事件驱动打破同步循环。核心思路:不再让 user-service 直接调用 order-service 的 API,而是让 order-service 在订单状态变更时发出事件,user-service 订阅事件并将最近订单摘要写入本地缓存。
代码对比
改动前——同步循环调用:
// ❌ 坏做法:user-service 直接调用 order-service
// user-service/profile.service.ts
class ProfileService {
constructor(private orderClient: OrderServiceClient) {}
async getUserProfile(userId: string) {
const user = await this.userRepo.findById(userId)
// 同步调用 order-service,形成循环依赖
const recentOrders = await this.orderClient.getRecentOrders(userId, 5)
return { ...user, recentOrders }
}
}改动后——事件驱动解耦:
// ✅ 好做法:order-service 发布事件,user-service 订阅
// order-service/order.events.ts — 发布方
export class OrderEventPublisher {
async publishOrderCreated(order: Order) {
await this.messageBus.publish('order.created', {
orderId: order.id,
userId: order.userId,
amount: order.totalAmount,
status: order.status,
createdAt: order.createdAt
})
}
}
// user-service/order-summary.handler.ts — 订阅方
export class OrderSummaryHandler {
// 收到事件后写入本地缓存,不再同步调用 order-service
async handle(event: OrderCreatedEvent) {
await this.orderSummaryRepo.upsert({
userId: event.userId,
orderId: event.orderId,
amount: event.amount,
status: event.status,
updatedAt: new Date()
})
}
}
// user-service/profile.service.ts — 读本地数据
class ProfileService {
constructor(private orderSummaryRepo: OrderSummaryRepository) {}
async getUserProfile(userId: string) {
const user = await this.userRepo.findById(userId)
// 从本地缓存读,不再有跨服务调用
const recentOrders = await this.orderSummaryRepo.findRecent(userId, 5)
return { ...user, recentOrders }
}
}事件驱动打破了循环依赖:user-service 不再需要知道 order-service 的存在,只消费它发出的事件。两个服务可以独立部署、独立扩展。
依赖方向:一张表看清谁该依赖谁
| 层级 | 可以依赖 | 不可以依赖 | 常见违规 |
|---|---|---|---|
| 页面层(pages) | features、components、hooks | — | 页面里写复杂业务逻辑 |
| 功能模块(features) | components、hooks、utils、types | pages | feature 之间直接互相 import |
| 通用组件(components) | hooks、utils、types | features、pages | common 组件反向引用业务代码 |
| Hooks | utils、types | components、features、pages | Hook 里直接操作 UI 组件 |
| 工具函数(utils) | types | 一切有业务逻辑的模块 | 工具函数里引入领域模型 |
| 类型定义(types) | — | 一切 | 类型文件里 import 运行时依赖 |
这张表的本质是一条规则:依赖方向从上到下,稳定的在下,易变的在上。类型定义最稳定,所以谁都可以依赖它;页面最易变,所以谁都不依赖它。
边界划分的三种策略对比
实际项目中,模块边界的划分并非只有一种方式。根据项目规模和团队组织,可以选择不同策略:
| 策略 | 适用场景 | 划分依据 | 优点 | 缺点 |
|---|---|---|---|---|
| 按技术层划分 | 小型项目、早期阶段 | controller / service / repository | 简单直观,新人易上手 | 业务逻辑分散,改动波及面大 |
| 按业务功能划分 | 中大型项目、领域清晰 | order / user / payment 等独立模块 | 改动隔离好,团队协作清晰 | 需要前期投入设计,公共逻辑需要额外处理 |
| 混合划分(推荐) | 大型 Monorepo、微前端 | 顶层按业务,底层按技术 | 兼顾业务隔离和技术复用 | 需要明确的依赖规则约束 |
我个人的经验是:项目初期按技术层划分完全没问题,但在业务功能超过 5 个、团队成员超过 6 人时,就应该考虑转向按业务功能划分。这个转折点如果错过,后续迁移成本会指数增长。
用自动化守住边界
人工 Review 可以发现问题,但无法阻止问题产生。真正可靠的边界守护需要自动化工具在 CI 中强制执行。
| 工具 | 语言/生态 | 能力 | 集成方式 |
|---|---|---|---|
| Dependency Cruiser | TypeScript / JavaScript | 检测循环依赖、可视化依赖图、声明式规则校验 | CLI + CI |
| ArchUnit | Java / Kotlin | 架构规则单元测试,可断言依赖方向 | 测试框架 |
| Nx Enforce | Monorepo(JS/TS) | 标签化模块、依赖约束规则 | lint 插件 |
| Import Lint | 多语言 | 基于配置的 import 路径限制 | ESLint / Biome 插件 |
| Madge | TypeScript / JavaScript | 循环依赖检测、依赖图生成 | CLI |
以 Dependency Cruiser 为例,可以在 .dependency-cruiser.mjs 中声明规则:
// .dependency-cruiser.mjs
export default {
forbidden: [
{
name: 'no-common-to-feature',
comment: '通用组件不得依赖业务功能模块',
from: { path: 'src/components/' },
to: { path: 'src/features/' }
},
{
name: 'no-feature-to-page',
comment: '功能模块不得依赖页面层',
from: { path: 'src/features/' },
to: { path: 'src/pages/' }
},
{
name: 'no-circular',
comment: '禁止循环依赖',
from: {},
to: { circular: true }
}
]
}在 CI 中跑 npx depcruise --validate .dependency-cruiser.mjs src/,违规直接阻断合并。团队不需要靠人肉记住规则,工具会在每次提交时自动检查。
模块边界检查清单
以下清单按项目生命周期阶段分组,可以用作 Code Review 或架构评审的参考:
设计阶段
- 每个模块能否用一句话说清「它负责什么」?如果不能,边界可能过大。
- 模块的外部接口是否已经定义(类型、Schema 或接口文档)?内部实现是否对外不可见?
- 依赖方向是否单向?是否存在两个模块互相 import 的情况?
- 模块之间通信是否走契约(类型/接口/事件),而不是直接引用对方的内部路径?
- 是否考虑了「如果删除这个模块,影响范围有多大」?
实现阶段
- 新增功能时,是否放在了职责匹配的模块中,而不是「最近的」或「最方便的」模块?
- 工具函数是否保持了纯函数特性,没有引入业务领域类型或副作用?
- 通用组件是否只接收展示所需的数据,不依赖特定业务模块的上下文?
- 跨模块数据传递是否通过 DTO 或接口类型,而不是直接传递数据库实体?
维护阶段
- 是否定期用工具扫描循环依赖?CI 中是否有架构规则校验?
- 需求变更时,改动的文件是否集中在一个模块内?如果散落多个模块,说明边界可能需要调整。
- 是否有模块长期没有变更但频繁被其他模块依赖?这可能是抽象层次不对的信号。
- 删除或替换某个模块时,是否需要修改其他模块的内部代码?如果是,说明耦合过紧。
- 团队是否能在不影响其他模块部署的情况下,独立发布某个模块的改动?
总结
模块边界设计的本质不是「文件怎么放」,而是「改动影响多大范围」。
- 边界清晰的系统,一个需求只改一个模块,Review 只需一两个人。
- 边界混乱的系统,一个需求穿透五六个目录,牵一发而动全身。
三个核心原则贯穿始终:
- 职责单一:每个模块只回答一个问题,改一个原因。
- 依赖单向:依赖方向自上而下,禁止循环,底层不感知上层。
- 契约通信:模块间通过类型、接口或事件通信,不暴露内部实现。
这些原则不依赖特定框架或语言。无论单体还是微服务、前端还是后端、新项目还是遗留系统,都适用。差别只在于落地时的粒度——小项目可以在目录层面执行,大项目可以在服务层面执行。
好的模块边界不会让系统变得「更大」,它只是让每个文件的归属更明确,让每次改动的范围更可控。
参考资料
- Clean Architecture — Robert C. Martin
- Developing Modular Software: Top Strategies and Best Practices — Vfunction
- Ten Principles of Modern Software Architecture Design — Navveen Balani
- Best Practice Software Architecture Principles 2026 — Medium
- Circular Dependencies in Software Design — Sky Soft Connections
- Avoid Cross Module Dependencies with Dependency Cruiser — DEV Community
- 模块化设计最佳实践 — 华为 HarmonyOS 开发者