模块边界设计:让改动停在该停的地方

0阅读10分钟

改动范围决定了系统的真实复杂度

做了几年的项目维护,我发现一个规律:系统复杂度增长后,真正让人头疼的问题往往不在某个函数写得差,而在模块之间的边界不清晰。一个看似简单的需求改动——比如「订单详情页加一个字段」——要穿过多个目录、多个服务、多个数据结构,改动的爆炸半径就会不断扩大。

我曾经参与过一个电商后台重构,最初只是想把「商品库存」逻辑从订单模块里拆出来独立管理。结果发现库存计算散落在订单服务、支付回调、仓储通知三个地方,涉及 7 个共享类型和 2 张数据库表。原本预计两周的改动,最后花了六周。

事后复盘时,我们意识到问题的根源不是代码质量差,而是模块边界从一开始就没有设计好。库存、订单、支付这三个职责在物理上被混在了一起,逻辑上也没有明确的契约来隔离它们。

这篇文章记录了我从那次经历中总结出的模块边界设计原则。核心目标只有一个:让改动停在该停的地方

理论基础:为什么边界比代码更重要

高内聚、低耦合——老原则的新解读

「高内聚、低耦合」几乎每个开发者都听过,但真正理解它在实际项目中含义的人并不多。Robert C. Martin 在 Clean Architecture 中把它拆成了两条可操作的规则:

  1. SREP(稳定复用原则):被复用的模块应当是稳定的,修改它会影响所有依赖方。
  2. SDP(稳定依赖原则):依赖方向必须指向更稳定的模块。

这两条规则指向同一个结论:模块边界不是文件划分,而是变更影响的隔离墙。一个模块如果同时承担数据读取、权限判断、业务流程和 UI 展示,那么任何一个需求的变动都可能穿透它,边界就形同虚设。

依赖方向必须单向流动

Martin Fowler 在讨论分层架构时反复强调一个观点:依赖方向应当自上而下,上层依赖下层,下层不依赖上层。这在实际工程中意味着——

  • 业务逻辑不应当直接 import 页面组件。
  • 数据访问层不应当硬编码 API 路由路径。
  • 通用工具函数不应当引入业务领域类型。

一旦出现反向依赖或循环依赖,模块之间的边界就失去了方向性。后续重构、替换和测试都会变得困难。

边界即契约

模块化设计的一个核心理念是:模块之间通过契约通信,而不是通过实现细节。契约可以是 TypeScript 类型、API Schema、事件协议或者 RPC 接口定义。调用方只需要知道契约,不需要知道对方内部怎么组织代码。

这种思想在微服务架构中被广泛实践(领域边界对应服务边界),在单体应用中同样适用——模块边界对应的不是部署单元,而是变更隔离单元

案例一:订单服务里的「万能模块」

场景

某 B2B 电商平台的订单服务 order-service 最初只有三个文件:order.controller.tsorder.service.tsorder.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 })
  }
}

拆分后的关键变化:PricingServiceInventoryService 各自独立,互不依赖。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 组件依赖了某个具体业务页面,拆不动。

类似的反向依赖有十几处,迁移成本远超预期。

修复

建立严格的分层依赖规则:

流程图画布 · 115%
Mermaid 流程图加载中...

核心规则:箭头方向就是依赖方向,永远单向向下,禁止反向引用

代码对比

改动前——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-serviceorder-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、typespagesfeature 之间直接互相 import
通用组件(components)hooks、utils、typesfeatures、pagescommon 组件反向引用业务代码
Hooksutils、typescomponents、features、pagesHook 里直接操作 UI 组件
工具函数(utils)types一切有业务逻辑的模块工具函数里引入领域模型
类型定义(types)一切类型文件里 import 运行时依赖

这张表的本质是一条规则:依赖方向从上到下,稳定的在下,易变的在上。类型定义最稳定,所以谁都可以依赖它;页面最易变,所以谁都不依赖它。

边界划分的三种策略对比

实际项目中,模块边界的划分并非只有一种方式。根据项目规模和团队组织,可以选择不同策略:

策略适用场景划分依据优点缺点
按技术层划分小型项目、早期阶段controller / service / repository简单直观,新人易上手业务逻辑分散,改动波及面大
按业务功能划分中大型项目、领域清晰order / user / payment 等独立模块改动隔离好,团队协作清晰需要前期投入设计,公共逻辑需要额外处理
混合划分(推荐)大型 Monorepo、微前端顶层按业务,底层按技术兼顾业务隔离和技术复用需要明确的依赖规则约束

我个人的经验是:项目初期按技术层划分完全没问题,但在业务功能超过 5 个、团队成员超过 6 人时,就应该考虑转向按业务功能划分。这个转折点如果错过,后续迁移成本会指数增长。

用自动化守住边界

人工 Review 可以发现问题,但无法阻止问题产生。真正可靠的边界守护需要自动化工具在 CI 中强制执行。

工具语言/生态能力集成方式
Dependency CruiserTypeScript / JavaScript检测循环依赖、可视化依赖图、声明式规则校验CLI + CI
ArchUnitJava / Kotlin架构规则单元测试,可断言依赖方向测试框架
Nx EnforceMonorepo(JS/TS)标签化模块、依赖约束规则lint 插件
Import Lint多语言基于配置的 import 路径限制ESLint / Biome 插件
MadgeTypeScript / 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 或架构评审的参考:

设计阶段

  1. 每个模块能否用一句话说清「它负责什么」?如果不能,边界可能过大。
  2. 模块的外部接口是否已经定义(类型、Schema 或接口文档)?内部实现是否对外不可见?
  3. 依赖方向是否单向?是否存在两个模块互相 import 的情况?
  4. 模块之间通信是否走契约(类型/接口/事件),而不是直接引用对方的内部路径?
  5. 是否考虑了「如果删除这个模块,影响范围有多大」?

实现阶段

  1. 新增功能时,是否放在了职责匹配的模块中,而不是「最近的」或「最方便的」模块?
  2. 工具函数是否保持了纯函数特性,没有引入业务领域类型或副作用?
  3. 通用组件是否只接收展示所需的数据,不依赖特定业务模块的上下文?
  4. 跨模块数据传递是否通过 DTO 或接口类型,而不是直接传递数据库实体?

维护阶段

  1. 是否定期用工具扫描循环依赖?CI 中是否有架构规则校验?
  2. 需求变更时,改动的文件是否集中在一个模块内?如果散落多个模块,说明边界可能需要调整。
  3. 是否有模块长期没有变更但频繁被其他模块依赖?这可能是抽象层次不对的信号。
  4. 删除或替换某个模块时,是否需要修改其他模块的内部代码?如果是,说明耦合过紧。
  5. 团队是否能在不影响其他模块部署的情况下,独立发布某个模块的改动?

总结

模块边界设计的本质不是「文件怎么放」,而是「改动影响多大范围」。

  • 边界清晰的系统,一个需求只改一个模块,Review 只需一两个人。
  • 边界混乱的系统,一个需求穿透五六个目录,牵一发而动全身。

三个核心原则贯穿始终:

  1. 职责单一:每个模块只回答一个问题,改一个原因。
  2. 依赖单向:依赖方向自上而下,禁止循环,底层不感知上层。
  3. 契约通信:模块间通过类型、接口或事件通信,不暴露内部实现。

这些原则不依赖特定框架或语言。无论单体还是微服务、前端还是后端、新项目还是遗留系统,都适用。差别只在于落地时的粒度——小项目可以在目录层面执行,大项目可以在服务层面执行。

好的模块边界不会让系统变得「更大」,它只是让每个文件的归属更明确,让每次改动的范围更可控。

参考资料

评论 0

0 / 1000