PAYATHON 2026

模块化企业应用的最佳 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 很快就会变成所有模块都依赖、却没人敢修改的“上帝模块”。

模块之间不要共享内部数据模型

“共享数据”通常不意味着多个模块可以直接读写同一批表。

以用户购买一本书为例,可以这样处理:

  1. Shopping 创建订单。
  2. Payment 完成付款。
  3. Payment 发布 PaymentCompleted。
  4. Shopping 确认订单并发布 BookPurchased。
  5. 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 更新数据。共享数据库只是一种部署方式,并不表示所有模块共享数据所有权。

如何安全地开启和关闭模块

要做到“关闭模块而不影响稳定性”,需要先定义“关闭”具体包含哪些操作:

  1. 禁止用户进入模块;
  2. 停止创建新的业务数据;
  3. 等待正在执行的请求完成;
  4. 停止定时任务和事件消费;
  5. 保留历史数据;
  6. 明确其他模块的降级方式。

建议至少实现以下状态:

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 包的可见性边界,但不负责用户授权、业务事件或数据库隔离。工具可以帮助维护边界,却不能代替业务架构。

推荐的实施顺序

  1. 按业务能力划分 identity、course、shopping、payment、forum 等模块。
  2. 明确每个模块拥有的数据表、公开接口和发布事件。
  3. 建立轻量 Core,只保留身份、授权、事件、审计等平台能力。
  4. 实现模块注册表和用户模块授权表。
  5. 对必须立即返回结果的调用使用同步接口。
  6. 通过领域事件传播跨模块状态。
  7. 引入 Outbox 和消费幂等机制。
  8. 实现 ENABLED、DRAINING、DISABLED 等生命周期状态。
  9. 使用架构测试,禁止模块访问其他模块的内部包。
  10. 只有出现独立部署或扩缩容需求时,才把特定模块拆成服务。

常见误区

  • 不要不断扩充所有模块共同继承的基类。
  • 不要允许多个模块任意修改同一张表。
  • 不要用一个 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 的数据库演进实践。

这类系统的长期维护成本,主要取决于每个模块能否真正拥有自己的业务规则和数据,并且只通过稳定契约与外部协作。只要边界足够清楚,系统今后无论继续采用模块化单体,还是逐步拆分成服务,都能保留足够的演进空间。

备注:内容仅供参考。