模块化单体:Web 版 ERP 的务实架构选择

每个做 Web 版 ERP 的团队迟早都会面对同一个问题:微服务还是单体?在交付了几套中型 ERP 系统之后,我们的默认答案是模块化单体——一个部署单元,内部模块严格隔离。本文解释为什么,以及如何防止单体退化成一坨泥。

为什么不用微服务

微服务解决的是组织问题:十几个工程师的团队各自独立发布、热点路径独立扩容、多语言技术栈被专门的工作负载证明合理。而一个典型的 Web 版 ERP 项目,团队五到二十人、一个发布窗口、一个共享关系库——财务、库存、生产数据本来就必须保持一致。

在这种条件下,微服务买来的 mostly 是成本:单据过账要处理分布式事务、用户能直接看到最终一致性的裂缝、每个服务重复一遍脚手架,还有服务发现、链路追踪、版本管理这套运维负担——小团队背不动。常见的失败模式大家都很熟悉:所谓"微服务"全都读写同一个数据库——两头吃亏。

模块化单体到底意味着什么

模块化单体是单一进程、单一部署,但内部按硬边界组织成一组模块。每个模块拥有自己的:

  • 数据表——跨模块没有外键,不许 JOIN 别人的表。
  • 公开 API——其他模块只能调用内存中的接口或应用服务,绝不直接碰别人的 repository。
  • 领域事件——状态变化通过内部事件总线广播,依赖模块订阅响应,而不是反向调用。

纪律性是模块化单体与分层单体的唯一区别。部署是统一的,代码库不是自由市场。

模块边界怎么切

按业务能力切分,对齐客户组织的思维方式:

  • 基础数据——物料、客户、供应商、仓库、计量单位、币种。
  • 库存——现存量、入库、出库、调拨、盘点。
  • 采购与销售——订单、收货、发货、价格。
  • 财务——科目表、凭证、应收应付、期末结账,见总账与业务单据一体化设计。
  • 生产——BOM、MRP、工单,见Web 版 ERP 的 BOM 展开与 MRP 运算实现。
  • 工作流——挂在任意模块单据上的审批流程。

两条经验法则:一个模块应该对应领域里一个有界上下文;跨模块的调用应该"稍微费劲"。如果调别人的模块和自己的代码一样顺手,那边界就不存在。

用工具强制边界

光靠约定会在 deadline 面前腐烂。用机械手段强制执行:

  1. 按模块分包,不按层分包。每个模块内部自带 controller、service、repository、entity。全局的 service 或 dao 包是泥球化的第一步。
  2. 静态分析门禁。ArchUnit(Java)或 dependency-cruiser(TypeScript)在模块 A 引用模块 B 内部实现时直接让构建失败,CI 每次提交都跑。
  3. 模块各自管理迁移。每个模块应用自己的 schema 迁移,共享迁移文件夹会掩盖跨模块的表耦合。
  4. 反应用事件总线。销售发货确认后,库存订阅 DeliveryConfirmed 事件而不是被销售模块直接调用,依赖图保持无环。

什么时候一个模块真的值得拆出去

当以下条件之一可以被度量地证实时,一个模块才配得上独立服务:

  • 扩容画像不同——比如报表生成把 CPU 打满,交易在排队。
  • 可用性要求不同——比如通知服务必须在核心系统月结停机时仍然存活。
  • 有独立团队负责,按不同的节奏发布。

因为边界在进程内已经被强制执行,把一个模块拆成服务主要是打包工作:把内存接口换成 HTTP 或消息客户端。从第一天就上微服务的团队要为每个模块预付这笔成本;模块化单体只在数据证明值得时才支付。

前端一侧

模块化不止于 API。Web 前端应镜像模块结构:每个模块一条路由子树、按功能组织代码、权限系统决定用户能看到哪些菜单——即企业级 Web 界面模式中描述的方案。

结语

对大多数 Web 版 ERP 项目来说,争论不是单体对微服务,而是有纪律的单体对失控的单体。选择模块化单体,用工具强制边界,让真实的运行数据告诉你某个模块是否该离开进程。你会发布得更快、排查得更轻松,而且逃生舱门一直开着。