领域驱动设计与Java企业级应用的行为驱动开发实践

本文深入探讨如何将领域驱动设计(DDD)与行为驱动开发(BDD)相结合,使用Java企业级技术栈构建酒店管理系统。通过实际代码示例展示DDD建模、Oracle NoSQL集成、Cucumber测试框架应用,实现业务需求与技术实现的无缝衔接。

应用领域驱动设计与Java企业级开发:行为驱动方法

在软件开发中,最大的错误之一就是精确交付客户所要求的内容。虽然这听起来有些老生常谈,但即使在行业经历数十年发展后,这个问题依然存在。更有效的方法是从业务需求出发开始测试。

行为驱动开发(BDD)是一种软件开发方法,强调行为和领域术语,也称为统一语言。它使用共享的自然语言从用户角度定义和测试软件行为。BDD在测试驱动开发(TDD)的基础上构建,专注于与业务相关的场景。这些场景被编写成纯语言规范,可以自动化为测试,同时也作为活文档。

这种方法促进了技术和非技术利益相关者之间的共同理解,确保软件满足用户需求,并有助于减少返工和开发时间。在本文中,我们将进一步探讨这种方法论,并讨论如何使用Oracle NoSQL和Java实现它。

BDD与DDD如何协同工作

乍看之下,行为驱动开发(BDD)和领域驱动设计(DDD)似乎解决了不同的问题——一个专注于测试,另一个专注于建模。然而,两者共享相同的哲学基础:确保软件真正反映其所服务的业务领域。

DDD由Eric Evans在其2003年的开创性著作《领域驱动设计:处理软件核心的复杂性》中提出,教导我们围绕业务概念——实体、值对象、聚合和限界上下文——来建模软件。其力量在于使用统一语言,这是一种连接开发人员和领域专家的共享词汇表。

BDD由Dan North在几年后提出,作为这一思想的自然延伸出现。它将统一语言带入测试过程,将业务规则转化为可执行的规范。DDD定义了系统应该表示什么,而BDD则验证系统如何按照该模型行为。

当一起使用时,DDD和BDD形成一个连续的反馈循环:

  • DDD塑造捕获业务逻辑的领域模型
  • BDD确保系统行为随时间推移与该模型保持一致

在实践中,这种协同意味着您可以编写功能场景——例如"当我预订VIP房间时,系统应将其标记为不可用"——直接关联到如Room和Reservation等聚合。这些测试成为开发人员和利益相关者的活文档,确保您的领域与真实业务需求保持一致。

代码示例

在这个示例中,我们将使用Java企业版和Oracle NoSQL数据库生成一个简单的酒店管理应用程序。

第一步是创建项目。由于我们使用Java SE,可以使用以下Maven命令生成:

1
2
3
4
5
6
7
8
9
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实现。

初始项目准备就绪后,我们将开始创建测试。请记住,BDD是TDD的扩展,包括统一语言——领域和业务之间的共享词汇表。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
Feature: Manage hotel rooms

  Scenario: Register a new room
    Given the hotel management system is operational
    When I register a room with number 203
    Then the room with number 203 should appear in the room list

  Scenario: Register multiple rooms
    Given the hotel management system is operational
    When I register the following rooms:
      | number | type      | status             | cleanStatus |
      | 101    | STANDARD  | AVAILABLE          | CLEAN       |
      | 102    | SUITE     | RESERVED           | DIRTY       |
      | 103    | VIP_SUITE | UNDER_MAINTENANCE  | CLEAN       |
    Then there should be 3 rooms available in the system

  Scenario: Change room status
    Given the hotel management system is operational
    And a room with number 101 is registered as AVAILABLE
    When I mark the room 101 as OUT_OF_SERVICE
    Then the room 101 should be marked as OUT_OF_SERVICE

Maven项目完成后,让我们进入下一步,即创建建模和存储库。如前所述,我们将专注于房间管理。因此,我们的下一个目标是确保之前定义的BDD测试通过。让我们开始实现领域模型和存储库:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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
}

@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轻松完成,它有一个单一的存储库,因此我们不需要担心实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@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实例来运行测试。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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管理的实例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@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,我们可以定义一个ObjectFactory,将类注入Cucumber测试上下文。由于我们使用CDI和Weld作为实现,我们将创建一个自定义的WeldCucumberObjectFactory来无缝集成这两种技术。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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

内容如下:

1
org.soujava.demos.hotels.config.WeldCucumberObjectFactory

我们将让Mapper将我们的表转换为所有模型中的Room。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@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测试。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
@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 should be initialized").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("Room %s should exist", number)
                .isPresent();

        Room updatedRoom = roomOpt.orElseThrow();
        updatedRoom.update(newStatus);

        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("Room %s should exist", number)
                .isPresent()
                .get()
                .extracting(Room::getStatus)
                .isEqualTo(expectedStatus);
    }
}

是时候执行测试了:

1
mvn clean test

您可以看到结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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结合。在《使用Java进行领域驱动设计》一书中,您可以找到理解为什么DDD对我们仍然重要的良好起点。它扩展了这里分享的想法,展示了DDD和BDD如何共同导致更简单、更可维护和更注重业务的软件。这种软件提供了超越需求的实际价值。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计