模块化企业应用的最佳 Design Pattern 与数据库设计
明确结论
没有哪一种 Design Pattern 能单独覆盖这些需求。更适合的方案是:
- 初始架构采用“模块化单体(Modular Monolith)”;
- 使用 Microkernel/Plugin Architecture 管理模块的安装、启用和停用;
- 使用 Hexagonal Architecture 隔离模块内部实现;
- 按业务边界划分模块,模块之间通过清晰的 API 和领域事件协作;
- 使用 RBAC 或 Entitlement 模型控制用户可使用的模块;
- 业务数据优先存入关系型数据库,只有确实需要内容仓库能力时才使用 Jackrabbit。
尤其要避免让业务模块通过继承一个庞大的 CoreModule 来获得功能。模块应依赖稳定的接口,而不是共享父类、数据库表或内部实体。随着系统扩展,继承会让模块越来越难以独立升级和关闭。组合、接口和事件更适合长期演进。
推荐的整体架构
系统可以分成四层:
Application
├── Core Platform
│ ├── Identity
│ ├── Authorization
│ ├── Module Registry
│ ├── Event Bus
│ ├── Audit
│ └── Transaction Infrastructure
│
├── Business Modules
│ ├── Course
│ ├── Payment
│ ├── Forum
│ └── Shopping
│
├── Integration Contracts
│ ├── Commands
│ ├── Queries
│ └── Domain Events
│
└── Infrastructure
├── Relational Database
├── Message Broker
├── Object Storage
└── Content Repository
Core 只负责技术基础设施和少量稳定的共享概念,例如:
- 用户身份标识;
- 权限检查;
- 模块注册与生命周期;
- 事件发布;
- 审计日志;
- 数据库事务;
- 时钟、ID 生成等基础服务。
Course、Shopping、Payment 等业务逻辑不应放进 Core。否则,Core 很快就会变成所有模块都依赖、却没人敢修改的“上帝模块”。
模块之间不要共享内部数据模型
“共享数据”通常不意味着多个模块可以直接读写同一批表。
以用户购买一本书为例,可以这样处理:
- Shopping 创建订单。
- Payment 完成付款。
- Payment 发布
PaymentCompleted。 - Shopping 确认订单并发布
BookPurchased。 - Course 订阅
BookPurchased,更新学生的学习资源或课程动态。
这样一来,Shopping 不需要直接修改 Course 的表。
public record BookPurchased(
UUID eventId,
UUID userId,
UUID orderId,
String isbn,
String bookTitle,
Instant occurredAt
) {
}
Course 模块负责处理这个事件:
public final class BookPurchasedHandler {
private final CourseActivityRepository repository;
public BookPurchasedHandler(CourseActivityRepository repository) {
this.repository = repository;
}
public void handle(BookPurchased event) {
if (repository.existsByEventId(event.eventId())) {
return;
}
repository.addPurchasedBook(
event.eventId(),
event.userId(),
event.isbn(),
event.bookTitle(),
event.occurredAt()
);
}
}
这里用 eventId 做幂等处理,防止同一事件被重复投递后生成两条购买记录。
如果模块运行在同一个进程中,可以先使用进程内事件总线。等到确实需要独立部署、独立扩缩容或故障隔离时,再换成 Kafka、RabbitMQ 等消息系统。不要仅仅为了“模块化”就提前拆成微服务。
用接口和组合替代模块继承
模块可以实现统一的生命周期接口,但不应继承带有大量业务逻辑的基类。
public interface ApplicationModule {
String id();
void start(ModuleContext context);
void stop();
HealthStatus health();
}
模块上下文只暴露受控的平台服务:
public interface ModuleContext {
EventPublisher eventPublisher();
AuthorizationService authorizationService();
Clock clock();
}
模块可以这样实现:
public final class ShoppingModule implements ApplicationModule {
private volatile boolean running;
@Override
public String id() {
return "shopping";
}
@Override
public void start(ModuleContext context) {
running = true;
}
@Override
public void stop() {
running = false;
}
@Override
public HealthStatus health() {
return running ? HealthStatus.UP : HealthStatus.STOPPED;
}
}
这里需要关注的不是接口本身,而是依赖方向:
业务模块 → Core 的稳定接口
Core ✗ 不依赖具体业务模块
模块 A ✗ 不依赖模块 B 的内部实现
如果一个模块确实需要同步调用另一个模块,应依赖对方公开的接口:
public interface PaymentGateway {
PaymentResult charge(PaymentRequest request);
}
接口应放在定义清楚的契约包中。调用方不能直接引用对方的数据库实体、Repository 或内部 Service。
用户模块分配应独立于角色
“学生”和“普通客户”属于业务角色,“能否使用 Shopping”属于模块授权,不应把两者写死在同一个字段中。
可以采用三层模型:
User → Role → Default Module Entitlements
User → Direct Module Entitlements
Module → Permissions
例如:
STUDENT默认拥有course、forum;CUSTOMER默认拥有shopping;payment可以是多个角色共用的能力;- 某个学生可以额外获得
shopping; - 模块内部再用权限区分读、写、退款、管理等操作。
权限检查不能只依靠前端隐藏菜单。后端的每个受保护入口都必须验证授权。
public interface ModuleAccessService {
boolean isEnabledForUser(UUID userId, String moduleKey);
}
public void createOrder(UUID userId, CreateOrderCommand command) {
if (!moduleAccessService.isEnabledForUser(userId, "shopping")) {
throw new AccessDeniedException("Shopping module is not enabled");
}
// 创建订单
}
数据库设计建议
用户、订单、付款、课程和授权等结构明确的业务数据适合存入关系型数据库。建议按模块划分 schema,或者至少使用清晰的表名前缀。
platform.users
platform.modules
platform.roles
platform.user_roles
platform.role_modules
platform.user_modules
platform.outbox_events
course.enrollments
course.learning_activities
course.purchased_resources
shopping.products
shopping.orders
shopping.order_items
payment.payments
payment.refunds
模块注册表
CREATE TABLE platform_module (
module_id VARCHAR(64) PRIMARY KEY,
display_name VARCHAR(128) NOT NULL,
status VARCHAR(16) NOT NULL,
module_version VARCHAR(32) NOT NULL,
installed_at TIMESTAMP NOT NULL,
CHECK (status IN ('ENABLED', 'DISABLED', 'MAINTENANCE'))
);
status 表示系统级状态,用户级授权需要单独存储。
用户与模块的对应关系
CREATE TABLE user_module_entitlement (
user_id UUID NOT NULL,
module_id VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
granted_by UUID,
granted_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP,
PRIMARY KEY (user_id, module_id),
FOREIGN KEY (user_id) REFERENCES app_user(user_id),
FOREIGN KEY (module_id) REFERENCES platform_module(module_id),
CHECK (status IN ('ACTIVE', 'SUSPENDED', 'REVOKED'))
);
如果角色可以默认授予模块,再增加:
CREATE TABLE role_module_entitlement (
role_id UUID NOT NULL,
module_id VARCHAR(64) NOT NULL,
PRIMARY KEY (role_id, module_id),
FOREIGN KEY (role_id) REFERENCES app_role(role_id),
FOREIGN KEY (module_id) REFERENCES platform_module(module_id)
);
实际权限由“角色默认授权”和“用户直接授权”共同决定。如果需要支持显式拒绝,还要定义清楚优先级,例如:
用户显式拒绝 > 用户显式授予 > 角色默认授予 > 默认拒绝
跨模块引用
模块之间应尽量只共享稳定 ID:
CREATE TABLE course_purchased_resource (
resource_id UUID PRIMARY KEY,
user_id UUID NOT NULL,
source_order_id UUID NOT NULL,
source_event_id UUID NOT NULL UNIQUE,
resource_type VARCHAR(32) NOT NULL,
external_ref VARCHAR(128),
title VARCHAR(255) NOT NULL,
purchased_at TIMESTAMP NOT NULL
);
Course 保存自己需要的业务投影,而不是直接查询 Shopping 的 order_item 表。这种做法会产生少量重复数据,但能换来清晰的模块边界,也让后续演进更安全。
事务与事件的一致性
如果“写入业务数据”和“发布事件”是两个独立操作,就可能出现订单已经提交、事件却没有发出的情况。这个问题可以用 Transactional Outbox Pattern 处理:
CREATE TABLE outbox_event (
event_id UUID PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id UUID NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload TEXT NOT NULL,
occurred_at TIMESTAMP NOT NULL,
published_at TIMESTAMP
);
在同一个数据库事务中完成:
写入订单
+
写入 outbox_event
=
提交事务
后台发布器读取尚未发布的事件并进行投递。消费者仍需支持幂等,因为消息系统通常无法代替业务层去重。
即使所有模块共用一个数据库,也不能随意跨 schema 更新数据。共享数据库只是一种部署方式,并不表示所有模块共享数据所有权。
如何安全地开启和关闭模块
要做到“关闭模块而不影响稳定性”,需要先定义“关闭”具体包含哪些操作:
- 禁止用户进入模块;
- 停止创建新的业务数据;
- 等待正在执行的请求完成;
- 停止定时任务和事件消费;
- 保留历史数据;
- 明确其他模块的降级方式。
建议至少实现以下状态:
ENABLED → DRAINING → DISABLED
↓
MAINTENANCE
把模块代码直接从 JVM 中卸载,通常比禁用入口复杂得多。大多数企业系统并不需要真正的运行时类卸载,通常做到以下几点就够了:
- 使用 Feature Flag 控制入口;
- 由授权层拒绝新请求;
- 停止该模块的消费者和调度任务;
- 保留只读查询;
- 向依赖模块返回明确的“能力不可用”结果;
- 设置超时、重试上限和 Circuit Breaker。
系统不能假设某个模块永远存在。调用模块能力时,“模块未启用”应被视为正常业务状态,而不是导致 NullPointerException 或启动失败。
数据库迁移不能跟随开关反复执行
启用或关闭模块时,不应自动创建或删除业务表。数据库结构应由 Flyway、Liquibase 等工具进行版本化迁移管理。
建议遵循这些规则:
- 安装或升级模块时执行向前迁移;
- 关闭模块时保留数据和表结构;
- 删除字段时采用 Expand-Migrate-Contract;
- 新旧版本并存期间保持契约兼容;
- 删除已卸载模块的数据时,使用独立、显式且可审计的管理操作。
“模块关闭”和“模块卸载”必须是两个不同的概念。关闭应当可逆,卸载则可能造成数据无法恢复。
Jackrabbit 是否适合
Apache Jackrabbit 是 JCR(Content Repository for Java)规范的一种实现,适合处理具有以下特征的内容:
- 内容采用层级结构;
- 需要版本、检入和检出;
- 需要元数据和全文检索;
- 需要权限控制;
- 内容结构可能频繁变化;
- 主要操作对象是文档、页面、媒体和内容节点。
如果系统需要管理文章、页面、附件、课程资料及其版本,Jackrabbit 可以作为专门的内容存储。
订单、付款、用户授权和课程注册等核心交易数据则不适合统一存入 Jackrabbit。使用前还需要考虑:
- 节点模型不同于传统关系型查询方式;
- 复杂报表和跨业务聚合不如 SQL 直接;
- 团队需要掌握 JCR 的节点、属性、版本和查询模型;
- 备份、索引、集群和迁移会增加运维成本;
- 如果需求只是上传文件并保存少量元数据,关系型数据库配合对象存储通常更简单。
比较稳妥的组合是:
PostgreSQL/MySQL
└── 用户、权限、订单、付款、课程等交易数据
Object Storage
└── 图片、PDF、视频和其他大型二进制文件
Jackrabbit/JCR(按需)
└── 具有层级、版本、全文检索和内容工作流需求的内容
在选型之前,可以先整理一组真实用例,包括内容树规模、版本数量、查询方式、权限继承、全文检索、备份恢复和集群部署。根据这些用例制作原型并验证,比看到系统有内容管理需求就直接引入 Jackrabbit 更稳妥。
相关资料可以查阅 JCR 规范、Apache Jackrabbit 官方文档及其部署与持久化说明。正式使用前,还要核对计划采用版本的维护状态、Java 兼容性和安全公告。
什么时候考虑 OSGi、JPMS 或插件框架
如果系统确实需要在运行时安装、卸载和隔离第三方模块,可以评估 OSGi 或专门的 Java 插件框架。这些方案可以提供模块发现、依赖描述和生命周期管理,但也会增加类加载、版本冲突和调试的复杂度。
如果所有模块都由同一团队开发并统一部署,通常可以先采用:
- Maven 或 Gradle 多模块工程;
- 使用 Java 接口控制依赖;
- 通过包级或构建级检查约束边界;
- 由 Spring 容器管理组件;
- 使用 Feature Flag 和授权表控制启用状态。
JPMS 可以强化 Java 包的可见性边界,但不负责用户授权、业务事件或数据库隔离。工具可以帮助维护边界,却不能代替业务架构。
推荐的实施顺序
- 按业务能力划分
identity、course、shopping、payment、forum等模块。 - 明确每个模块拥有的数据表、公开接口和发布事件。
- 建立轻量 Core,只保留身份、授权、事件、审计等平台能力。
- 实现模块注册表和用户模块授权表。
- 对必须立即返回结果的调用使用同步接口。
- 通过领域事件传播跨模块状态。
- 引入 Outbox 和消费幂等机制。
- 实现
ENABLED、DRAINING、DISABLED等生命周期状态。 - 使用架构测试,禁止模块访问其他模块的内部包。
- 只有出现独立部署或扩缩容需求时,才把特定模块拆成服务。
常见误区
- 不要不断扩充所有模块共同继承的基类。
- 不要允许多个模块任意修改同一张表。
- 不要用一个
user_type字段表达所有角色和模块权限。 - 不要因为系统需要“模块化”就立即采用微服务。
- 不要把 Feature Flag 当作后端授权机制。
- 不要在关闭模块时删除模块数据。
- 不要把事件总线当成强一致事务。
- 不要让共享 DTO 逐渐演变成整个系统统一的业务模型。
- 不必追求“完美数据库设计”,更实际的目标是明确数据所有权,并保证系统可迁移、可审计。
学习资料方向
可以重点查阅以下主题:
- Domain-Driven Design:Bounded Context、Aggregate、Domain Event;
- Patterns of Enterprise Application Architecture:Repository、Unit of Work、Data Mapper;
- Enterprise Integration Patterns:消息通道、事件路由和幂等消费者;
- Microkernel/Plugin Architecture;
- Hexagonal Architecture 与 Ports and Adapters;
- Transactional Outbox、Saga 和 Eventually Consistent View;
- RBAC、ABAC 与 Entitlement Management;
- JCR 规范和 Apache Jackrabbit 官方文档;
- Flyway 或 Liquibase 的数据库演进实践。
这类系统的长期维护成本,主要取决于每个模块能否真正拥有自己的业务规则和数据,并且只通过稳定契约与外部协作。只要边界足够清楚,系统今后无论继续采用模块化单体,还是逐步拆分成服务,都能保留足够的演进空间。
备注:内容仅供参考。
