在企业级 Java 中应用领域驱动设计:一种行为驱动方法
了解如何结合 DDD 和 BDD 于企业级 Java 中,以创建能够模拟真实业务领域并通过可执行场景验证行为的软件。

在软件开发领域,最大的错误之一就是交付客户"精确"想要的东西。这听起来可能像陈词滥调,但即使在行业摸爬滚打数十年后,这个问题依然存在。一个更有效的方法是从关注业务需求开始测试。
行为驱动开发【Behavior-driven development】(BDD)是一种强调行为和领域术语(也称为统一语言)的软件开发方法论。它使用共享的自然语言,从用户的角度定义和测试软件行为。BDD 建立在测试驱动开发【test-driven development】(TDD)的基础上,专注于与业务相关的场景。这些场景以纯语言规范的形式编写,可以自动化成测试,同时也充当活文档。
这种方法促进了技术和非技术利益相关者之间的共识,确保软件满足用户需求,并有助于减少返工和开发时间。在本文中,我们将进一步探讨这种方法论,并讨论如何使用 Oracle NoSQL 和 Java 来实现它。
BDD 与 DDD 如何协同工作
乍看之下,行为驱动开发(BDD)和领域驱动设计(DDD)似乎解决的是不同的问题——一个侧重于测试,另一个侧重于建模。然而,它们共享相同的哲学基础:确保软件真实反映其所服务的业务领域。
DDD,由 Eric Evans 在其 2003 年具有开创性的著作《领域驱动设计:软件核心复杂性的应对之道》中提出,教导我们围绕业务概念(实体、值对象、聚合和限界上下文)来建模软件。其力量在于使用统一语言,这是一种连接开发人员和领域专家的共享词汇表。
BDD,由 Dan North 在几年后提出,是这一思想自然而然的延伸。它将统一语言引入测试过程,将业务规则转化为可执行的规范。DDD 定义了系统应该表示什么,而 BDD 则根据该模型验证系统的行为方式。
当结合使用时,DDD 和 BDD 形成了一个持续的反馈循环:
- DDD 塑造了捕获业务逻辑的领域模型。
- BDD 确保系统行为随着时间的推移与该模型保持一致。
在实践中,这种协同作用意味着您可以编写与聚合(如 Room 和 Reservation)直接相关的特性场景——例如"当我预订一个 VIP 房间时,系统应将其标记为不可用"。这些测试成为开发人员和利益相关者的活文档,确保您的领域始终与真实的业务需求保持一致。
如果您想深入探索这种结合,我的著作《Domain-Driven Design with Java》详细阐述了这些原则。它展示了如何在现代 Java 应用程序中使用 Jakarta EE、Spring 和云技术应用 DDD 模式,为统一架构和行为提供了实践基础。
总之,DDD 和 BDD 共同弥合了理解业务与证明其可行之间的差距——将软件从技术制品转变为领域本身的忠实表达。
代码实现
在本示例中,我们将使用企业级 Java 和 Oracle NoSQL 数据库生成一个简单的酒店管理应用程序。
第一步是创建项目。由于我们使用的是 Java SE,我们可以使用以下 Maven 命令生成它:
mvn archetype:generate \
"-DarchetypeGroupId=io.cucumber" \
"-DarchetypeArtifactId=cucumber-archetype" \
"-DarchetypeVersion=7.30.0" \
"-DgroupId=org.soujava.demos.hotel" \
"-DartifactId=behavior-driven-development" \
"-Dpackage=org.soujava.demos" \
"-Dversion=1.0.0-SNAPSHOT" \
"-DinteractiveMode=false"
下一步是引入 Eclipse JNoSQL 与 Oracle NoSQL,以及 Jakarta EE 组件的实现:CDI、JSON 和 Eclipse MicroProfile 实现。
您可以找到完整的 pom.xml 文件。
初始项目准备就绪后,我们将从创建测试开始。
请记住,BDD 是 TDD 的扩展,它包含了统一语言——领域和业务之间的共享词汇。
功能: 管理酒店房间
场景: 注册一个新房间
假设 酒店管理系统正在运行
当 我注册一个号码为 203 的房间
那么 号码为 203 的房间应该出现在房间列表中
场景: 注册多个房间
假设 酒店管理系统正在运行
当 我注册以下房间:
| number | type | status | cleanStatus |
| 101 | STANDARD | AVAILABLE | CLEAN |
| 102 | SUITE | RESERVED | DIRTY |
| 103 | VIP_SUITE | UNDER_MAINTENANCE | CLEAN |
那么 系统中应该有 3 个可用房间
场景: 更改房间状态
假设 酒店管理系统正在运行
并且 一个号码为 101 的房间已注册为 AVAILABLE
当 我将房间 101 标记为 OUT_OF_SERVICE
那么 房间 101 应被标记为 OUT_OF_SERVICE
Maven 项目完成后,让我们进入下一步,即创建建模和存储库。如前所述,我们将专注于房间管理。因此,我们的下一个目标是确保之前定义的 BDD 测试通过。让我们从实现领域模型和存储库开始:
public enum CleanStatus {
CLEAN, // 清洁
DIRTY, // 脏污
INSPECTION_NEEDED // 需要检查
}
public enum RoomStatus {
AVAILABLE, // 可用
RESERVED, // 已预订
UNDER_MAINTENANCE, // 维护中
OUT_OF_SERVICE // 停止服务
}
public enum RoomType {
STANDARD, // 标准间
DELUXE, // 豪华间
SUITE, // 套房
VIP_SUITE // VIP套房
}
@Entity
public class Room {
@Id
private String id;
@Column
private int number; // 房间号
@Column
private RoomType type; // 房间类型
@Column
private RoomStatus status; // 房间状态
@Column
private CleanStatus cleanStatus; // 清洁状态
@Column
private boolean smokingAllowed; // 允许吸烟
@Column
private boolean underMaintenance; // 处于维护状态
}
有了模型,下一步是创建企业级 Java 与作为非关系型数据库的 Oracle NoSQL 之间的桥梁。我们可以使用 Jakarta Data 非常轻松地完成,它只有一个存储库接口,所以我们不需要担心实现。
@Repository
public interface RoomRepository {
@Query("FROM Room")
List<Room> findAll();
@Save
Room save(Room room);
void deleteBy();
Optional<Room> findByNumber(Integer number);
}
项目完成后,下一步是准备测试环境,首先提供一个数据库实例用于测试。多亏了 Testcontainers,我们可以轻松启动一个隔离的 Oracle NoSQL 实例来运行我们的测试。
public enum DatabaseContainer {
INSTANCE;
private final GenericContainer<?> container = new GenericContainer<>
(DockerImageName.parse("ghcr.io/oracle/nosql:latest-ce"))
.withExposedPorts(8080);
{
container.start();
}
public DatabaseManager get(String database) {
DatabaseManagerFactory factory = managerFactory();
return factory.apply(database);
}
public DatabaseManagerFactory managerFactory() {
var configuration = DatabaseConfiguration.getConfiguration();
Settings settings = Settings.builder()
.put(OracleNoSQLConfigurations.HOST, host())
.build();
return configuration.apply(settings);
}
public String host() {
return "http://" + container.getHost() + ":" + container.getFirstMappedPort();
}
}
之后,我们将创建一个与 @Alternative CDI 注解集成的生产者。此配置指导 CDI 如何提供数据库实例——在本例中是由 Testcontainers 管理的实例:
@ApplicationScoped
@Alternative
@Priority(Interceptor.Priority.APPLICATION)
public class ManagerSupplier implements Supplier<DatabaseManager> {
@Produces
@Database(DatabaseType.DOCUMENT)
@Default
public DatabaseManager get() {
return DatabaseContainer.INSTANCE.get("hotel");
}
}
借助 Cucumber,我们可以定义一个将类注入到 Cucumber 测试上下文中的 ObjectFactory。由于我们使用 CDI 并以 Weld 作为实现,我们将创建一个自定义的 WeldCucumberObjectFactory 来无缝集成这两种技术。
public class WeldCucumberObjectFactory implements ObjectFactory {
private Weld weld;
private WeldContainer container;
@Override
public void start() {
weld = new Weld();
container = weld.initialize();
}
@Override
public void stop() {
if (weld != null) {
weld.shutdown();
}
}
@Override
public boolean addClass(Class<?> stepClass) {
return true;
}
@Override
public <T> T getInstance(Class<T> type) {
return (T) container.select(type).get();
}
}
一个重要提示:此设置作为 SPI(服务提供者接口)工作。因此,您必须创建以下文件:
src/test/resources/META-INF/services/io.cucumber.core.backend.ObjectFactory
内容如下:
org.soujava.demos.hotels.config.WeldCucumberObjectFactory
我们将让 Mapper 将我们的数据表转换为所有模型中的 Room 对象。
@ApplicationScoped
public class RoomDataTableMapper {
@DataTableType
public Room roomEntry(Map<String, String> entry) {
return Room.builder()
.number(Integer.parseInt(entry.get("number")))
.type(RoomType.valueOf(entry.get("type")))
.status(RoomStatus.valueOf(entry.get("status")))
.cleanStatus(CleanStatus.valueOf(entry.get("cleanStatus")))
.build();
}
}
整个测试基础设施完成后,下一步是设计包含我们实际测试的 Step 测试类。
@ApplicationScoped
public class HotelRoomSteps {
@Inject
private RoomRepository repository;
@Before
public void cleanDatabase() {
repository.deleteBy();
}
@Given("the hotel management system is operational")
public void theHotelManagementSystemIsOperational() {
Assertions.assertThat(repository).as("RoomRepository 应该已初始化").isNotNull();
}
@When("I register a room with number {int}")
public void iRegisterARoomWithNumber(Integer number) {
Room room = Room.builder()
.number(number)
.type(RoomType.STANDARD)
.status(RoomStatus.AVAILABLE)
.cleanStatus(CleanStatus.CLEAN)
.build();
repository.save(room);
}
@Then("the room with number {int} should appear in the room list")
public void theRoomWithNumberShouldAppearInTheRoomList(Integer number) {
List<Room> rooms = repository.findAll();
Assertions.assertThat(rooms)
.extracting(Room::getNumber)
.contains(number);
}
@When("I register the following rooms:")
public void iRegisterTheFollowingRooms(List<Room> rooms) {
rooms.forEach(repository::save);
}
@Then("there should be {int} rooms available in the system")
public void thereShouldBeRoomsAvailableInTheSystem(int expectedCount) {
List<Room> rooms = repository.findAll();
Assertions.assertThat(rooms).hasSize(expectedCount);
}
@Given("a room with number {int} is registered as {word}")
public void aRoomWithNumberIsRegisteredAs(Integer number, String statusName) {
RoomStatus status = RoomStatus.valueOf(statusName);
Room room = Room.builder()
.number(number)
.type(RoomType.STANDARD)
.status(status)
.cleanStatus(CleanStatus.CLEAN)
.build();
repository.save(room);
}
@When("I mark the room {int} as {word}")
public void iMarkTheRoomAs(Integer number, String newStatusName) {
RoomStatus newStatus = RoomStatus.valueOf(newStatusName);
Optional<Room> roomOpt = repository.findByNumber(number);
Assertions.assertThat(roomOpt)
.as("房间 %s 应该存在", number)
.isPresent();
Room updatedRoom = roomOpt.orElseThrow();
updatedRoom.update(newStatus); // 假设 Room 类有 update 方法
repository.save(updatedRoom);
}
@Then("the room {int} should be marked as {word}")
public void theRoomShouldBeMarkedAs(Integer number, String expectedStatusName) {
RoomStatus expectedStatus = RoomStatus.valueOf(expectedStatusName);
Optional<Room> roomOpt = repository.findByNumber(number);
Assertions.assertThat(roomOpt)
.as("房间 %s 应该存在", number)
.isPresent()
.get()
.extracting(Room::getStatus)
.isEqualTo(expectedStatus);
}
}
是时候执行测试了:
mvn clean test
您可以看到结果:
INFO: Connecting to Oracle NoSQL database at http://localhost:61325 using ON_PREMISES deployment type
✔ Given the hotel management system is operational # org.soujava.demos.hotels.HotelRoomSteps.theHotelManagementSystemIsOperational()
✔ And a room with number 101 is registered as AVAILABLE # org.soujava.demos.hotels.HotelRoomSteps.aRoomWithNumberIsRegisteredAs(java.lang.Integer,java.lang.String)
✔ When I mark the room 101 as OUT_OF_SERVICE # org.soujava.demos.hotels.HotelRoomSteps.iMarkTheRoomAs(java.lang.Integer,java.lang.String)
✔ Then the room 101 should be marked as OUT_OF_SERVICE # org.soujava.demos.hotels.HotelRoomSteps.theRoomShouldBeMarkedAs(java.lang.Integer,java.lang.String)
Oct 21, 2025 6:18:43 PM org.jboss.weld.environment.se.WeldContainer shutdown
INFO: WELD-ENV-002001: Weld SE container fc4b3b51-fba8-4ea6-9cef-42bcee97d220 shut down
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.231 s -- in org.soujava.demos.hotels.RunCucumberTest
[INFO] Running org.soujava.demos.hotels.MongoDBTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.007 s -- in org.soujava.demos.hotels.MongoDBTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO]
结论
通过结合领域驱动设计(DDD)和行为驱动开发(BDD),开发人员可以超越技术正确性,构建真正反映业务意图的软件。DDD 为领域提供了结构,确保模型精确地捕捉现实世界的概念,而 BDD 则通过用业务本身的语言编写的清晰、可测试的场景,确保这些模型按预期运行。
在本文中,您学习了如何使用 Oracle NoSQL、Eclipse JNoSQL 和 Jakarta EE 连接这两个世界——从定义您的领域到运行由 Cucumber 和 CDI 支持的真实行为测试。这种协同作用将测试转化为活文档,弥合了工程师和利益相关者之间的差距,并确保您的系统在演进过程中始终与业务目标保持一致。
您可以深入探索并将 DDD 与 BDD 结合起来。在《Domain-Driven Design with Java》这本书中,您可以找到一个很好的起点来理解为什么 DDD 对我们仍然很重要。它扩展了这里分享的想法,展示了 DDD 和 BDD 如何共同带来更简单、更易维护且以业务为中心的软件。这种软件交付的是超越需求的实际价值。
【注】本文译自:Applying Domain-Driven Design With Enterprise Java: A Behavior-Driven Approach

浙公网安备 33010602011771号