Apache Causeway在SpringBoot上兑现DDD承诺,开发效率翻倍


领域驱动设计是战略!Causeway把DDD的承诺兑现到代码层。

Spring MVC开发web应用有一套标准工序。收到请求先经过控制器层解析参数,再调用服务层处理业务,然后把结果塞进数据传输对象,最后交给视图模板渲染成HTML。每新增一个功能页面,这四层代码各写一遍。

Apache Causeway是一个构建在Spring Boot之上的领域驱动设计框架。领域驱动设计把业务领域作为软件的核心,所有技术决策围绕领域模型展开。

最大特点:这个DDD框架省略了控制器、页面模板、前端表单这三层代码。这三样东西由框架从领域模型自动生成

在本教程中,我们将使用 Apache Causeway 3.6.0、Java 21 和 Maven 构建一个小型资产管理应用程序。具体来说,我们将对笔记本电脑、显示器和手机等资产进行建模,添加生命周期操作和业务规则,运行生成的 UI,并调用生成的 REST 端点之一。

我们只用Java类描述公司资产是什么、能做什么、规则是什么,框架在运行期自动生成后台管理界面和API接口。

本文拆解这个领域驱动设计框架如何用元模型替代重复的Web层代码,覆盖实体建模、动作注解、业务规则校验、服务注入和RESTful API暴露等核心机制。

领域驱动设计框架把领域模型作为唯一事实来源

Spring Boot生态中的传统开发路径是控制器到服务到仓库到视图。领域驱动设计框架Causeway把这条链子砍断了一半。它只认领域对象。对象的状态用属性字段表示,行为用动作方法表示,规则用校验方法表示。

框架在启动时扫描这些类,构建一个运行期元模型。元模型是框架对领域结构的内部表示。它记录了每个类有哪些属性、哪些动作、哪些校验规则。Wicket视图器和RESTful Objects视图器共用这个元模型。两个视图器从同一套描述中生成界面和API。

增加一个资产归还功能需要新建控制器类吗,需要写表单提交逻辑吗,需要配置路由映射吗。这些步骤统统可以跳过。只需要在Asset实体上加一个returnToInventory方法,配上@Action注解。视图器自动在界面中生成按钮,在API中生成端点。开发速度的提升来自重复代码的消除。当业务规则变更时修改一处实体类即可。视图器和API同步更新,界面逻辑落后于业务逻辑的情况因此消失。

领域驱动设计把领域模型作为系统的事实来源。控制器层和视图层都从模型派生。模型变化时派生层自动适应。传统开发中模型变更需要同步修改三层代码,每层都有自己的表述方式。领域驱动设计框架把三层表述压缩为一层。领域专家和开发人员看到的术语一致。财务软件中的计提折旧在代码中就叫计提折旧,在界面中还是计提折旧。术语翻译消耗的成本降为零。领域驱动设计的核心理念在此体现:代码即设计,模型即实现。

领域驱动设计中的实体承载状态与行为双重职责

Asset类同时承担了JPA持久化映射和Causeway领域描述两个职责。JPA是Java持久化API的缩写,负责将Java对象存入关系数据库。这种多重身份要求代码同时满足两套注解体系。@Entity和@Table负责数据库映射。@DomainObject和@DomainObjectLayout负责领域驱动设计框架识别。@Named注解给了资产一个逻辑名称assets.Asset。这个名称独立于Java包路径。视图器和API端点通过逻辑名称引用服务,重构包名时外部调用因此免受破坏。

JPA的@Column定义了字段长度和非空约束。领域驱动设计框架的@PropertyLayout定义了界面中属性的分组和排序。两者共存于同一个getter方法上,互无干扰。这种设计让数据库模式和界面布局在同一个地方表达。修改字段长度时,数据库约束和界面输入框长度一起更新。一致性因此得到保证,因为信息源只有一个。持久化层和展示层共享同一份元数据。

下面这个pom.xml文件引入了领域驱动设计框架的核心依赖:


<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.apache.causeway.app</groupId>
        <artifactId>causeway-app-starter-parent</artifactId>
        <version>3.6.0</version>
        <relativePath/>
    </parent>
    <groupId>com.jdon</groupId>
    <artifactId>apache-causeway</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <!-- 名称和描述已省略。 -->
    <dependencies>
        <dependency>
            <groupId>org.apache.causeway.mavendeps</groupId>
            <artifactId>causeway-mavendeps-webapp</artifactId>
            <type>pom</type>
        </dependency>
        <dependency>
            <groupId>org.apache.causeway.viewer</groupId>
            <artifactId>causeway-viewer-wicket-viewer</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.causeway.viewer</groupId>
            <artifactId>causeway-viewer-restfulobjects-jaxrsresteasy</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.causeway.security</groupId>
            <artifactId>causeway-security-simple</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.causeway.persistence</groupId>
            <artifactId>causeway-persistence-jpa-eclipselink</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.causeway.viewer</groupId>
            <artifactId>causeway-viewer-wicket-applib</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-instrument</artifactId>
        </dependency>
        <!-- JUnit、Mockito和Causeway测试依赖已省略。 -->
    </dependencies>
    <!-- EclipseLink织入和Spring Boot打包插件已省略。 -->
    <properties>
        <java.version>21</java.version>
        <maven.compiler.release>21</maven.compiler.release>
    </properties>
</project>


webapp bundle提供了公共运行时依赖。viewer artifacts添加了生成的Wicket界面和RESTful Objects API。Simple Security和H2让示例保持自包含。

接下来需要一个Spring配置类来标识应用程序代码:

java
@Configuration
@ComponentScan(basePackageClasses = Assets.class)
@EnableJpaRepositories(basePackageClasses = AssetRepository.class)
@EntityScan(basePackageClasses = Asset.class)
public class AssetManagementModule {
}

逐行拆解这段代码的含义:@ComponentScan发现领域服务。@EnableJpaRepositories启用Spring Data仓库。@EntityScan注册JPA实体。然后把此配置与所需的领域驱动设计框架模块一起导入AppManifest:

java
@Configuration
@Import({
    CausewayModuleApplibMixins.class,
    CausewayModuleCoreRuntimeServices.class,
    CausewayModuleSecuritySimple.class,
    CausewayModulePersistenceJpaEclipselink.class,
    CausewayModuleViewerRestfulObjectsJaxrsResteasy.class,
    CausewayModuleViewerWicketApplibMixins.class,
    CausewayModuleViewerWicketViewer.class,
    AssetManagementModule.class
})
@PropertySource(CausewayPresets.NoTranslations)
public class AppManifest {
    // 密码编码和仅用于演示的SimpleRealm配置已省略
}

导入一个模块会将其Spring beans和领域驱动设计框架功能提供给应用程序。仓库还配置了H2、EclipseLink schema创建、演示用户和菜单布局。在application.yml中设置causeway.applib.annotation.action.explicit为true,这样只有显式用@Action注解的方法才成为动作。

领域驱动设计中的动作携带语义而非仅仅是方法调用

@Action注解的semantics参数有实际作用。SAFE标记告诉框架该操作修改状态吗,答案是否定的,视图器因此可以缓存结果。IDEMPOTENT标记表示多次调用效果相同,框架因此允许重复提交。IDEMPOTENT_ARE_YOU_SURE在Wicket视图器中触发确认对话框。这些语义信息在Java方法签名中无处存放,方法签名只描述输入输出。领域驱动设计框架在运行时读取注解,据此调整交互流程。

assignTo方法返回this,让视图器刷新当前对象而非跳转页面。returnToInventory清空assignee并重置状态为AVAILABLE。retire方法将状态变为RETIRED且过程不可逆。这些动作都直接修改实体字段。这里没有DTO拷贝,没有状态转移表,没有额外的事务脚本。业务逻辑写在实体内部,而写在服务层之外。这符合领域驱动设计中的充血模型。传统贫血模型把状态和行为分离,实体只存数据,服务处理逻辑。充血模型把行为和状态放一起,实体既能回答问题也能执行命令。

持久化类型需要稳定的逻辑名称。定义如下:

java
@Entity
@Table(
    schema = "assets",
    name = "Asset",
    uniqueConstraints = @UniqueConstraint(
        name = "AssetserialNumberUNQ",
        columnNames = "serial_number"
    )
)
@EntityListeners(CausewayEntityListener.class)
@Named("assets.Asset")
@DomainObject
@DomainObjectLayout
public class Asset implements Comparable {
    protected Asset() {
    }
    public Asset(final AssetType type, final String serialNumber) {
        this.type = type;
        this.serialNumber = serialNumber;
        this.status = AssetStatus.AVAILABLE;
    }
    // 标识符、版本、比较辅助方法和下文讨论的成员已省略
}

代码的具体含义如下:JPA注解映射实体,并在数据库层面强制serial_number的唯一性约束。@Named提供逻辑标识符,领域驱动设计框架独立于Java包名使用该标识符。@DomainObject显式标记该类为领域驱动设计框架的领域对象。CausewayEntityListener将JPA生命周期事件连接到领域驱动设计框架,包括服务注入和生命周期通知。公共构造函数建立第一个生命周期不变量:每个新资产以AVAILABLE状态开始。

JPA映射存储值,领域驱动设计框架识别对应的getter作为属性。看身份属性:

java
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false, length = 20)
private AssetType type;
@PropertyLayout(fieldSetId = LayoutConstants.FieldSetId.IDENTITY, sequence = "1")
public AssetType getType() {
    return type;
}
@Column(name = "serial_number", nullable = false, length = 80)
private String serialNumber;
@Title(prepend = "Asset: ")
@Property(maxLength = 80)
@PropertyLayout(fieldSetId = LayoutConstants.FieldSetId.IDENTITY, sequence = "2")
public String getSerialNumber() {
    return serialNumber;
}
// 状态和assignedTo已省略,聚焦于身份属性

几个关键细节:EnumType.STRING存储LAPTOP这类值,因此避免了使用脆弱的序数。@PropertyLayout在生成的UI中对成员进行分组和排序。@Title使序列号成为对象显示标题的一部分。剩余属性持有AssetStatus和可选的员工姓名。

领域驱动设计中的校验方法控制交互而非事后抛异常

领域驱动设计框架用命名约定把校验方法与主动作绑定。disableAssignTo返回null表示允许执行,返回字符串表示禁用并显示该消息。validate0AssignTo校验第一个参数,返回null表示通过,返回字符串表示参数错误并展示在界面中。这些校验方法在动作执行前被领域驱动设计框架调用,拦截无效请求。这种机制和Spring Validation的@Valid注解功能类似,但触发时机有差异。@Valid在参数绑定阶段校验,校验失败抛出异常由全局处理器转换。领域驱动设计框架的校验方法直接返回消息字符串,框架拿到消息后展示在视图器中,因此绕过了异常处理链路。

校验逻辑和动作方法放在同一个类中,修改规则时因此免去了跨文件跳转。disableRetire方法检查两种无效状态,ASSIGNED资产需要先归还,RETIRED资产禁止再次退休。两条规则写在一个方法里,分别返回不同消息。这种粒度让用户界面能够展示精确的阻止原因,而避免了笼统的操作失败提示。

首先赋值动作记录员工并更改状态:

java
@Action(semantics = SemanticsOf.IDEMPOTENT)
@ActionLayout(
    fieldSetId = LayoutConstants.FieldSetId.DETAILS,
    position = ActionLayout.Position.PANEL,
    describedAs = "将可用资产分配给一名员工"
)
public Asset assignTo(
    @Parameter(maxLength = 100)
    @ParameterLayout(named = "员工") final String employee) {
    assignedTo = employee.trim();
    status = AssetStatus.ASSIGNED;
    return this;
}

领域驱动设计框架将assignTo()渲染为动作,并从参数元数据派生其提示。通过返回this,告诉视图器继续使用更新后的资产。IDEMPOTENT语义描述预期的调用语义,但Java实现的幂等性需要自行保证。

其他生命周期动作将资产归还库存或报废:

java
@Action(semantics = SemanticsOf.IDEMPOTENT)
@ActionLayout(
    fieldSetId = LayoutConstants.FieldSetId.DETAILS,
    position = ActionLayout.Position.PANEL,
    describedAs = "将已分配的资产归还到库存"
)
public Asset returnToInventory() {
    assignedTo = null;
    status = AssetStatus.AVAILABLE;
    return this;
}
@Action(semantics = SemanticsOf.IDEMPOTENT_ARE_YOU_SURE)
@ActionLayout(
    fieldSetId = LayoutConstants.FieldSetId.DETAILS,
    position = ActionLayout.Position.PANEL,
    describedAs = "永久报废一件资产"
)
public Asset retire() {
    assignedTo = null;
    status = AssetStatus.RETIRED;
    return this;
}

returnToInventory()清除assignee并将状态恢复为AVAILABLE。报废操作故意设计为显式操作,IDEMPOTENT_ARE_YOU_SURE会让Wicket视图器弹出确认框。这些方法表达了状态变更,但每个变更何时有效仍需要额外控制。

领域驱动设计框架通过命名约定将支持方法与领域成员关联。例如disableAssignTo控制assignTo()的可用性,validate0AssignTo校验第一个参数:

java
@MemberSupport
public String disableAssignTo() {
    return status == AssetStatus.AVAILABLE
        ? null
        : "只有可用资产才能被分配";
}
@MemberSupport
public String validate0AssignTo(final String employee) {
    return employee == null || employee.isBlank()
        ? "员工姓名不能为空"
        : null;
}
@MemberSupport
public String disableReturnToInventory() {
    return status == AssetStatus.ASSIGNED
        ? null
        : "只有已分配的资产才能被归还";
}
@MemberSupport
public String disableRetire() {
    if (status == AssetStatus.ASSIGNED) {
        return "报废前请先归还资产";
    }
    return status == AssetStatus.RETIRED
        ? "该资产已经报废"
        : null;
}

null结果允许交互。字符串消息会禁用或拒绝操作,并向视图器或API客户端提供原因。只有可用资产才能分配,只有已分配资产才能归还,已分配资产必须先归还才能报废。@MemberSupport还让领域驱动设计框架能够验证支持方法仍然匹配现有的领域成员。

字符串参数默认是必填的,领域驱动设计框架在调用动作前会拒绝空的员工字段。校验器还处理仅包含空白字符的输入。这些检查适用于领域驱动设计框架管理的交互。直接通过Java调用assignTo()仍然是普通方法调用,支持方法因此不会被自动调用。业务规则成为领域交互的一部分,两个生成的视图器都能强制执行它们,控制器或页面中因此免去了重复的条件判断。

领域驱动设计中的领域服务作为入口而非仓储暴露

实体动作只能操作单个现有资产。创建和查询操作属于领域服务层。Assets类用@DomainService标记,被领域驱动设计框架纳入元模型。它注入RepositoryService和AssetRepository两个依赖。RepositoryService是领域驱动设计框架提供的持久化抽象,AssetRepository是Spring Data JPA接口。create方法调用repositoryService.persist把新实体写入数据库。validate1Create校验序列号是否重复,先去AssetRepository查询是否存在相同序列号。

java
@Named("assets.Assets")
@DomainService
@Priority(PriorityPrecedence.EARLY)
public class Assets {
    private final RepositoryService repositoryService;
    private final AssetRepository assetRepository;
    @Inject
    public Assets(
        final RepositoryService repositoryService,
        final AssetRepository assetRepository) {
        this.repositoryService = repositoryService;
        this.assetRepository = assetRepository;
    }
    @Action(semantics = SemanticsOf.NON_IDEMPOTENT)
    @ActionLayout(promptStyle = PromptStyle.DIALOG_MODAL)
    public Asset create(
        @ParameterLayout(named = "类型") final AssetType type,
        @Parameter(maxLength = 80)
        @ParameterLayout(named = "序列号") final String serialNumber) {
        return repositoryService.persist(new Asset(type, serialNumber.trim()));
    }
    @MemberSupport
    public String validate1Create(final String serialNumber) {
        if (serialNumber == null || serialNumber.isBlank()) {
            return "序列号不能为空";
        }
        return assetRepository.findBySerialNumberIgnoreCase(serialNumber.trim()).isPresent()
            ? "该序列号的资产已存在"
            : null;
    }
    // listAll()出现在API部分;findBySerialNumber()在完整项目中
}

@DomainService将服务包含在元模型中,@Named分配REST视图器使用的逻辑名称,@Priority将其放在Spring排序的早期。RepositoryService通过领域驱动设计框架的抽象持久化新实体,AssetRepository提供应用特定查询。

仓库本身使用Spring Data派生查询,因此不需要实现类:

java
public interface AssetRepository extends JpaRepository {
    List findAllByOrderBySerialNumberAsc();
    List findBySerialNumberContainingIgnoreCaseOrderBySerialNumberAsc(
        String serialNumber
    );
    Optional findBySerialNumberIgnoreCase(String serialNumber);
}

方法名描述了Spring Data生成的排序、部分匹配和不区分大小写的精确匹配。这将持久化查询与面向领域驱动设计框架的服务动作分开。支持方法名validate1Create指向create()的第二个参数。它拒绝空白序列号和持久化前已存在的不区分大小写重复项。数据库约束额外防止并发的精确值重复。在数据库层面强制不区分大小写的唯一性需要规范化、合适的索引或不区分大小写的排序规则。

最后menubars.layout.xml将三个服务动作显式放置在Assets菜单中:


<!-- menuBars根、命名空间、主容器和非Assets菜单已省略。 -->
<mb3:menu>
    <mb3:named>Assets</mb3:named>
    <mb3:section>
        <mb3:serviceAction objectType="assets.Assets" id="create"/>
        <mb3:serviceAction objectType="assets.Assets" id="findBySerialNumber"/>
        <mb3:serviceAction objectType="assets.Assets" id="listAll"/>
    </mb3:section>
</mb3:menu>


objectType值匹配服务的逻辑名称,每个id匹配一个动作方法。@DomainService在元模型中注册服务,每个@Action暴露一个方法,布局文件决定菜单分组。

领域驱动设计框架的入口点

应用程序入口点激活Causeway原型预设,然后启动Spring Boot:

java
@SpringBootApplication
@Import(AppManifest.class)
public class AssetManagementApplication extends SpringBootServletInitializer {
    public static void main(final String args) {
        CausewayPresets.prototyping();
        SpringApplication.run(AssetManagementApplication.class, args);
    }
}

从apache-causeway模块构建并运行应用:

bash
mvn clean install
mvn spring-boot:run

打开http://localhost:8080/wicket/,使用演示凭证admin和pass登录。

从Assets菜单选择Create,选择Laptop并输入LT-001。领域驱动设计框架立即使用标题、属性、字段集和动作渲染新对象。打开Assign To并提交空员工,必填参数阻止调用,生成提示显示校验错误。输入Alice后,动作将资产变为ASSIGNED。同一页面现在启用Return To Inventory,禁用Assign To和Retire,反映支持方法的状态,页面特定的条件代码因此失去了存在的必要。这里没有资产特定的控制器、表单或HTML模板。原型预设、内存H2数据库、自动schema创建和SimpleRealm凭证仅适用于本地示例。生产应用应使用持久存储、schema迁移、生产安全和生产就绪的领域驱动设计框架配置。

领域驱动设计框架的生成API

服务将其列表操作标记为安全:

java
@Action(semantics = SemanticsOf.SAFE)
public List listAll() {
    return assetRepository.findAllByOrderBySerialNumberAsc();
}

RESTful Objects视图器将此动作映射到经过身份验证的GET。应用运行时可使用演示账户调用:

bash
$ curl -i -u admin:pass \
    -H 'Accept: application/json' \
    'http://localhost:8080/restful/services/assets.Assets/actions/listAll/invoke'

URL包含逻辑服务名assets.Assets和动作标识符listAll。验证调用返回200 OK并到达领域服务,AssetRepository因此未被直接暴露。响应是RESTful Objects DomainObjectList表示,包含链接、关系类型和媒体类型配置文件,允许客户端导航领域。这对通用客户端和集成有用,但在作为公共版本化API使用前,应刻意限制暴露的表面或定义客户端特定表示。

领域驱动设计框架把DDD的战略价值变成了战术工具。控制器和模板代码从项目中消失,开发流程回归到业务建模本身。

总之:Apache Causeway是一个基于Spring Boot的领域驱动型应用程序框架。它不从控制器和页面入手,而是先描述领域对象、它们的状态、行为和业务规则。Causeway 会在运行时将这些描述转换为元模型。之后,Wicket 查看器和 RESTful 对象查看器使用相同的元模型来提供 Web 用户界面和超媒体 API。这使得 Causeway 对内部管理软件尤为有用,因为在这些软件中,广泛的领域覆盖和快速反馈通常比自定义界面更为重要。