模块化单体:Web 版 ERP 的务实架构选择
每个做 Web 版 ERP 的团队迟早都会面对同一个问题:微服务还是单体?在交付了几套中型 ERP 系统之后,我们的默认答案是模块化单体——一个部署单元,内部模块严格隔离。本文解释为什么,以及如何防止单体退化成一坨泥。
为什么不用微服务
微服务解决的是组织问题:十几个工程师的团队各自独立发布、热点路径独立扩容、多语言技术栈被专门的工作负载证明合理。而一个典型的 Web 版 ERP 项目,团队五到二十人、一个发布窗口、一个共享关系库——财务、库存、生产数据本来就必须保持一致。
在这种条件下,微服务买来的 mostly 是成本:单据过账要处理分布式事务、用户能直接看到最终一致性的裂缝、每个服务重复一遍脚手架,还有服务发现、链路追踪、版本管理这套运维负担——小团队背不动。常见的失败模式大家都很熟悉:所谓"微服务"全都读写同一个数据库——两头吃亏。
模块化单体到底意味着什么
模块化单体是单一进程、单一部署,但内部按硬边界组织成一组模块。每个模块拥有自己的:
- 数据表——跨模块没有外键,不许 JOIN 别人的表。
- 公开 API——其他模块只能调用内存中的接口或应用服务,绝不直接碰别人的 repository。
- 领域事件——状态变化通过内部事件总线广播,依赖模块订阅响应,而不是反向调用。
纪律性是模块化单体与分层单体的唯一区别。部署是统一的,代码库不是自由市场。
模块边界怎么切
按业务能力切分,对齐客户组织的思维方式:
- 基础数据——物料、客户、供应商、仓库、计量单位、币种。
- 库存——现存量、入库、出库、调拨、盘点。
- 采购与销售——订单、收货、发货、价格。
- 财务——科目表、凭证、应收应付、期末结账,见总账与业务单据一体化设计。
- 生产——BOM、MRP、工单,见Web 版 ERP 的 BOM 展开与 MRP 运算实现。
- 工作流——挂在任意模块单据上的审批流程。
两条经验法则:一个模块应该对应领域里一个有界上下文;跨模块的调用应该"稍微费劲"。如果调别人的模块和自己的代码一样顺手,那边界就不存在。
用工具强制边界
光靠约定会在 deadline 面前腐烂。用机械手段强制执行:
- 按模块分包,不按层分包。每个模块内部自带 controller、service、repository、entity。全局的
service或dao包是泥球化的第一步。 - 静态分析门禁。ArchUnit(Java)或 dependency-cruiser(TypeScript)在模块 A 引用模块 B 内部实现时直接让构建失败,CI 每次提交都跑。
- 模块各自管理迁移。每个模块应用自己的 schema 迁移,共享迁移文件夹会掩盖跨模块的表耦合。
- 反应用事件总线。销售发货确认后,库存订阅
DeliveryConfirmed事件而不是被销售模块直接调用,依赖图保持无环。
什么时候一个模块真的值得拆出去
当以下条件之一可以被度量地证实时,一个模块才配得上独立服务:
- 扩容画像不同——比如报表生成把 CPU 打满,交易在排队。
- 可用性要求不同——比如通知服务必须在核心系统月结停机时仍然存活。
- 有独立团队负责,按不同的节奏发布。
因为边界在进程内已经被强制执行,把一个模块拆成服务主要是打包工作:把内存接口换成 HTTP 或消息客户端。从第一天就上微服务的团队要为每个模块预付这笔成本;模块化单体只在数据证明值得时才支付。
前端一侧
模块化不止于 API。Web 前端应镜像模块结构:每个模块一条路由子树、按功能组织代码、权限系统决定用户能看到哪些菜单——即企业级 Web 界面模式中描述的方案。
结语
对大多数 Web 版 ERP 项目来说,争论不是单体对微服务,而是有纪律的单体对失控的单体。选择模块化单体,用工具强制边界,让真实的运行数据告诉你某个模块是否该离开进程。你会发布得更快、排查得更轻松,而且逃生舱门一直开着。