文章是 长读 。我建议您提前查看Table of contents
。也许有些部分比其他部分更有趣。
我喜欢Hibernate。即使它很复杂(和error-prone sometimes),我认为这是一个非常有用的框架。如果您知道如何烹饪,它会发光。
尽管Hibernate是在实施JPA方面,但每次我的意思是JPA时,我都会说 Hibernate 。只是为了简单。
许多Java开发人员在其项目中使用Hibernate。但是,我注意到一种奇怪的趋势。 Java Dev几乎每次都应用Anemic Domain Model pattern。每当我询问采取该策略的原因时,我都会得到以下答案:
- 我们以前一直做过它,而且效果很好。
- 我们的开发人员习惯了这种体系结构。
- 我们还有其他选择吗?
这就是为什么我决定提出这篇文章的原因。我想用冬眠的贫血领域模型使用status quo,并向您提出一些不同的建议。那就是Rich Domain Model pattern。
在本文中,我告诉您:
- 什么是丰富的域模型?
- 贫血领域模型有什么问题,富人如何解决问题?
- 逐步解决方案。
- 丰富的领域模型有哪些缺点?
您可以通过this link的代码示例查看整个存储库。
目录
- 贫血领域模型的问题
- Too Smart Services
- 可能不变的违规
- 缺乏封装
- 测试更难
- 丰富的领域模型原理
- 请勿添加设定器,Getters和Public no-args构造函数
- no-args构造函数允许不稳定的对象实例化
- 设置破裂封装
- Getters Break封装
- 当前结果
- 聚合和聚集根
- 要求的演变
- 每个口袋必须至少拥有一个tamagotchi
- 每个tamagotchi名称都必须在口袋中唯一
- 如果用户删除了tamagotchi,则可以按名称恢复它
- 查询数据
- 手动查询
- 介绍Todto方法
- 单元测试实体
- 口袋必须始终拥有至少一个tamagotchi
- 如果删除tamagotchi,则可以按名称恢复
- 如果您删除具有相同名称的多个Tamagotchi,则只能还原最后一个
- 集成测试
- 创建口袋
- camagaghogho li>
- 更新tamago li>
- 绩效影响
- 优化查询
- 精确指出优化的检查
- 数据库生成的ID
- 手动填写ID
- 介绍业务密钥
- 丰富的领域模型总是值得的吗?
- 结论
- 资源
贫血模型的问题
首先,让我们讨论域。我们将开发Tamagotchi应用程序。 Pocket可能有许多Tamagotchi
实例,但是每个Tamagotchi
属于一个Pocket
。因此,关系是Pocket --one-to-many-> Tamagotchi
。
最有可能以这种方式实现贫血域模型解决方案:
我敢打赌,您看到了许多类似的Java代码。但是该解决方案包含许多问题。让我们一一讨论他们。
太聪明的服务
贫血领域模型要求服务层具有所有业务逻辑。实体充当虚拟数据结构。但是实体不是静态的。他们在此期间发展。我们可能会添加一些字段并删除其他字段。否则我们可以将现有字段组合在Embeddable
对象中。
在这里,服务必须了解与他们合作的实体的每个次要细节。因为任何操作都可能需要访问不同的字段。这意味着即使实体的略有变化也可能导致许多服务的重大重组。实际上,这打破了Open-Closed principle。代码不是面向对象的,而是程序性的。我们不使用OOP范式的好处。相反,我们带来了其他困难。
可能不变的违规行为
Invariant是一项业务规则,仅允许对实体进行某些更改。它保证我们不会将实体传输到错误的状态。例如,假设Pocket
默认情况下可能仅包含三个Tamagotchi
。如果您想拥有更多,则需要购买高级订阅。那是一个不变的。如果用户不购买其他功能,则必须禁止将第四个Tamagotchi
添加到Pocket
。
如果我们选择贫血域模型方法,则意味着服务必须检查不变性并在需要时取消操作。但是不变的也不是静态的。想象一下,从项目开始就没有引入Pocket
中的三个Tamagotchi
规则。但是我们现在要添加它。这意味着我们必须检查所有可能创建新的Tamagotchi
并添加相应检查的方法和功能。
如果更改更广泛,情况变得更糟。假设Tamagotchi
已成为Saga pattern的一部分。现在,它包含具有PENDING
值的status
字段。如果Tamagotchi
是PENDING
,则不能删除它也不能更新它。你知道我要去哪里吗?您必须检查更新或删除Tamagotchi
的每个代码,并确保您不会错过任何检查PENDING
状态的检查。
缺乏封装
OOP中的Encapsulation是一种限制对某些数据的直接访问的机制。这就说得通了。实体可能有几个字段,但这并不意味着我们要允许更改每个字段。我们可能只会同时更改混凝土字段。仅当实体传输到特定状态时,才允许其他允许更新。
贫血领域模型迫使我们放弃封装,并将@Getter
和@Setter
从Lombok进行了注释而不考虑后果。
违反封装的最大问题是,代码变得更加危险。您不能只调用setName
或setStatus
方法。但是,您必须确保提前检查特定条件。同样,不变的是静态的。因此,对一个实体的每个突变都像是一个地雷。如果您错过单个条件检查,您不知道接下来会发生什么。
测试更难
主要是开发人员将Hibernate与Spring Boot结合使用。这意味着服务是带有@Transactional annotation的常规春豆。通常,这些服务包含spaghetti code的实体,存储库和其他服务调用。在测试方面,我看到开发人员选择一个选项:
- 集成测试。
- Mocking一切。
不误会我的意思。我认为集成测试至关重要。 Tescontainers库特别有助于使过程变得顺利。但是,我认为集成测试的计数应尽可能最低。如果您可以通过简单的单元测试来验证某些内容,请这样做。将过多的集成测试带入项目也带来了某些困难:
- 集成测试很难维护。
- 总是有共享资源(在这种情况下,数据库)。因此,测试可能意外地彼此依赖。测试可能会得到flaky。
- 很难并行运行集成测试。
- 这些测试要慢得多。如果您的项目已经足够老,并且您有许多集成测试,则常规的CI构建可以在30分钟甚至更长的时间内运行。
嘲笑呢?我认为这样的测试几乎没有用。我的意思是一般不是一个坏主意。但是,如果您尝试嘲笑弹簧数据JPA存储库和其他服务的每个呼叫,则可能会发生you don’t test the behaviour。您只需验证模拟调节的正确顺序即可。因此,测试变得脆弱,并承担着巨大的负担。
丰富的领域模型原理
相反,丰富的域模型模式提出了一种不同的方法。查看下面的图。
您可以看到,实体持有所需的业务逻辑。当服务像薄层一样,委派给存储库和实体。
丰富的域模型与tactical patterns of Domain Driven Design相关。我们感兴趣的是aggregate。
聚合是一个域对象的簇,您可以将其视为一个整个单元。例如,Pocket
有许多Tamagotchis
。这意味着Pocket
和Tamagotchi
可以是一个汇总。汇总根是允许直接访问聚集并保证不变性的实体。因此,如果我们想在Tamagotchi
中更改某些内容,我们只能与Pocket
进行互动。
通过介绍丰富的域模型,我想解决这些问题:
- 代码应变得更加面向业务。如果逻辑分配在许多服务之间,则很难理解实际的操作流(尤其是对于新移民)。
- 让编译器验证您的代码。如果一个实体都有每个字段的设置器,则应在调用时进行额外检查。但是,如果一个实体仅提供一定量的突变状态的方法,则意味着由于编译器检查而无法进行不正确的过渡。另外,如果不存在方法,则无法调用它。因此,最好仅提供需要的操作。
- 减少集成测试的量。如果可能的话,最好通过简单的单元测试来测试业务逻辑。因此,我想在不违反质量保证水平的情况下替换一些单位测试。
让我们从重构Pocket
和Tamagotchi
开始到富领域模型开始我们的旅程。
不要添加固定器,getters和公共no-args构造函数
首先,查看设计Pocket
和Tamagotchi
实体的最初方法,按照贫血域模型:
@Entity
@NoArgsConstructor
@Setter
@Getter
public class Pocket {
@Id
private UUID id;
private String name;
@OneToMany(mappedBy = "pocket")
private List<Tamagotchi> tamagotchis = new ArrayList<>();
}
@Entity
@NoArgsConstructor
@Setter
@Getter
public class Tamagotchi {
@Id
private UUID id;
private String name;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "pocket_id")
private Pocket pocket;
}
在这里,我将UUID用作主要键。我知道对此有一些绩效影响。但是现在,客户端生成的ID对于平稳过渡到富域模型至关重要。无论如何,以后我会给您一些其他ID类型的示例。
我敢打赌这看起来很熟悉。也许您当前的项目包含许多类似的声明。有什么问题?
NO-ARGS构造函数允许不稳定的对象实例化
冬眠demands each entity to provide a no-args constructor。否则,该框架无法正常工作。这是一种前卫的情况之一,可以使您的代码不那么直接,而更多的buggy。
值得庆幸的是,有一个解决方案。 Hibernate不需要一个实体的 public 构造函数。相反,它可以受到保护。因此,我们可以添加公共静态方法来实例化实体,并专门为Hibernate留下保护构造函数。查看下面的代码示例:
@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter
@Getter
public class Pocket {
@Id
private UUID id;
private String name;
@OneToMany(mappedBy = "pocket")
private List<Tamagotchi> tamagotchis = new ArrayList<>();
public static Pocket newPocket(String name) {
Pocket pocket = new Pocket();
pocket.setId(UUID.randomUUID());
pocket.setName(name);
return pocket;
}
}
@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter
@Getter
public class Tamagotchi {
@Id
private UUID id;
private String name;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "pocket_id")
private Pocket pocket;
public static Tamagotchi newTamagotchi(String name, Pocket pocket) {
Tamagotchi tamagotchi = new Tamagotchi();
tamagotchi.setId(UUID.randomUUID());
tamagotchi.setName(name);
tamagotchi.setPocket(pocket);
return tamagotchi;
}
}
您可以看到,业务代码(可能在其他软件包中)无法使用no-args构造函数实例化Tamagotchi
或Pocket
。它必须调用专用方法newTamagotchi
和newPocket
,该方法接受特定量的参数。
设定器断开封装
我认为 public setters与常规 public 字段大不相同。好吧,您可以在设置器中放一些检查,因为这是一种方法。但实际上,人们往往不会这样。通常,我们只是将Lombok库的@Setter
注释在上课的顶部。
我考虑在实体中使用设置器,因此是由于以下原因是不良方法:
- 可能违反不变的人。某些字段无法更新。仅当实体被传输到特定状态时,才能更新其他。纯固定器迫使开发人员将所有这些支票放入服务中。
- 如果
Tamagotchi.name
是String
,则不意味着允许每个String
值。因此,您还必须执行实体的支票。 - 字段可以成为实施细节的一部分。也许禁止直接更新它。但是A public setter允许此操作。
要点是, public 设置破坏了我之前提到的编译器验证的原理。您只提供了太多可以用不同的选择。
替代方案是什么?我建议添加用于特定行为的changeXXX
方法。另外,这些方法应包含验证逻辑,并在需要时投掷异常。
假设Tamagotchi
实体具有一个可以具有PENDING
值的status
字段。如果Tamagotchi
是PENDING
,则无法修改。查看下面的代码示例:
@Entity
@NoArgsConstructor(access = PROTECTED)
@Getter
public class Tamagotchi {
@Id
private UUID id;
private String name;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "pocket_id")
private Pocket pocket;
@Enumerated(STRING)
private Status status;
public void changeName(String name) {
if (status == PENDING) {
throw new TamagotchiStatusException("Tamagotchi cannot be modified because it's PENDING");
}
if !(nameIsValid(name)) {
throw new TamagotchiNameInvalidException("Invalid Tamagotchi name: " + name);
}
this.name = name;
}
public static Tamagotchi newTamagotchi(String name, Pocket pocket) { /* entity creation */ }
}
Tamagotchi.changeName
方法可以确保如果违反某些先决条件,则不能更改name
。调用该方法的代码不需要了解特定规则。您只需要处理例外。
Getters断开封装
好吧,关于设置者的上一段或多或少是显而易见的。互联网上有数十种文章和意见,内容涉及二传手的问题。无论如何,消除Getters听起来很荒谬,不是吗?他们没有突变一个实体的状态。那么,这笔交易是什么?
Getters的问题是,它们还允许打破封装并执行不必要或错误的检查。假设如果其状态为ERROR
,我们还想限制更新Tamagotchi
的名称。这是您在代码评论中可能看到的解决方案:
@Service
@RequiredArgsConstructor
public class TamagotchiService {
private final TamagotchiRepository repo;
@Transactional
public void changeName(UUID id, String name) {
Tamagotchi tamagotchi = repo.findById(id).orElseThrow();
if (tamagotchi.getStatus() == ERROR) {
throw new TamagotchiStatusException("Tamagotchi cannot be modified because its status is ERROR");
}
tamagotchi.changeName(name);
}
}
尽管Tamagotchi
提供了专用的方法changeName
,但该检查仍在服务层中实现。我注意到,即使有可能的高级开发人员也倾向于陷入 贫血模型心态 时。因为他们已经从事不同的项目多年了,并且很可能每个项目都采用了贫血领域模型。因此,开发人员只选择更简单,更明显的方法。
但是,一个决定会带来一些后果。首先,逻辑分配在Tamagotchi
实体和TamagotchiService
之间(这是我们想避免的一件事)。其次,检查可能会重复,您可能会在代码审查中错过。最后,有些检查可以及时过时。例如,这种验证ERROR
状态可能以后会过时。如果您忘记在这里消除它,您的代码会预计会采取行动。
正如我之前提到的,如果您不需要方法,请不要添加它。执行业务逻辑所需的Getters。您可以在Tamagotchi.changeName
方法中放置验证。如果不存在getter,则不能调用它,并且这种情况不会发生。
那呢?通常,我们使用Hibernate实体将其用于
SELECT
数据,将其转换为DTO,然后将结果返回给用户。我们如何在没有Getters的情况下做到这一点?不用担心,我们将在文章后面讨论此主题。
此规则也有一个例外。您可以为ID添加Getters。有时,有必要在运行时知道实体ID。后来您会看到一个例子。
当前结果
我们已经讨论了三点:
- no-args构造函数。
- 设置。
- Getters。
如果我们删除了这些作品,则代码看起来像这样:
@Entity
@NoArgsConstructor(access = PROTECTED)
public class Pocket {
@Id
private UUID id;
private String name;
@OneToMany(mappedBy = "pocket")
private List<Tamagotchi> tamagotchis = new ArrayList<>();
public static Pocket newPocket(String name) {
Pocket pocket = new Pocket();
pocket.setId(UUID.randomUUID());
pocket.setName(name);
return pocket;
}
}
@Entity
@NoArgsConstructor(access = PROTECTED)
public class Tamagotchi {
@Id
private UUID id;
private String name;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "pocket_id")
private Pocket pocket;
@Enumerated(STRING)
private Status status;
public void changeName(String name) {
if (status == PENDING) {
throw new TamagotchiStatusException("Tamagotchi cannot be modified because it's PENDING");
}
if (!nameIsValid(name)) {
throw new TamagotchiNameInvalidException("Invalid Tamagotchi name: " + name);
}
this.name = name;
}
public static Tamagotchi newTamagotchi(String name, Pocket pocket) {
Tamagotchi tamagotchi = new Tamagotchi();
tamagotchi.setId(UUID.randomUUID());
tamagotchi.setName(name);
tamagotchi.setPocket(pocket);
tamagotchi.setStatus(CREATED);
return tamagotchi;
}
}
汇总和聚集根
以前我提到了总模式。在谈到我们的域,Pocket
实体应该是总词根。但是,现有的API允许我们直接访问Tamagotchi
实体。让我们修复。
首先,让我们添加简单的CREATE/UPDATE/DELETE
操作。查看下面的代码示例:
@Entity
@NoArgsConstructor(access = PROTECTED)
public class Pocket {
@Id
private UUID id;
private String name;
@OneToMany(mappedBy = "pocket", cascade = PERSIST, orphanRemoval = true)
private List<Tamagotchi> tamagotchis = new ArrayList<>();
public UUID createTamagotchi(TamagotchiCreateRequest request) {
Tamagotchi tamagotchi = Tamagotchi.newTamagotchi(request.name(), this);
tamagotchis.add(tamagotchi);
return tamagotchi.getId();
}
public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
Tamagotchi tamagotchi = tamagotchiById(tamagotchiId);
tamagotchi.changeName(request.name());
}
public void deleteTamagotchi(UUID tamagotchiId) {
Tamagotchi tamagotchi = tamagotchiById(tamagotchiId);
tamagotchis.remove(tamagotchi);
}
private Tamagotchi tamagotchiById(UUID tamagotchiId) {
return tamagotchis
.stream()
.filter(t -> t.getId().equals(tamagotchiId))
.findFirst()
.orElseThrow(() -> new TamagotchiNotFoundException("Cannot find Tamagotchi by ID=" + tamagotchiId));
}
public static Pocket newPocket(String name) {
Pocket pocket = new Pocket();
pocket.setId(UUID.randomUUID());
pocket.setName(name);
return pocket;
}
}
@Entity
@NoArgsConstructor(access = PROTECTED)
class Tamagotchi {
@Id
@Getter
private UUID id;
private String name;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "pocket_id")
private Pocket pocket;
@Enumerated(STRING)
private Status status;
public void changeName(String name) {
if (status == PENDING) {
throw new TamagotchiStatusException("Tamagotchi cannot be modified because it's PENDING");
}
if (!nameIsValid(name)) {
throw new TamagotchiNameInvalidException("Invalid Tamagotchi name: " + name);
}
this.name = name;
}
public static Tamagotchi newTamagotchi(String name, Pocket pocket) {
Tamagotchi tamagotchi = new Tamagotchi();
tamagotchi.setId(UUID.randomUUID());
tamagotchi.setName(name);
tamagotchi.setPocket(pocket);
tamagotchi.setStatus(CREATED);
return tamagotchi;
}
}
有很多细微差别。因此,我会指出他们每个人一个。首先,Pocket
实体提供方法createTamagotchi
,updateTamagotchi
和deleteTamagotchi
AS-IS。您没有从Tamagotchi
或Pocket
中检索任何信息。您只需调用所需功能。
我知道这样的技术也受到绩效惩罚。我们还将讨论一些以后克服这些问题的方法。
然后进行Tamagotchi
实体。我要您注意的第一件事是该实体是 package-Provate 。这意味着没有人可以在包装外访问Tamagotchi
。因此,直接调用Pocket
是唯一的方法。
现在,您可能认为其利润并不是那么明显。但是很快我们将讨论总体的演变,您会看到好处。
Pocket
和Tamagotchi
实体均未提供常规的设定器或get子。一个人只能调用Pocket
实体的公共方法。
需求的演变
正如我之前说的,实体不是静态的。需求也会改变和不变。因此,让我们仔细研究实施新要求的假设过程。
每个口袋必须至少拥有一个tamagotchi
这意味着当新的Pocket
实例化时,我们应该创建一个Tamagotchi
。另外,如果要删除Tamagotchi
,则必须检查它不是Pocket
中的单个。查看下面的代码示例:
@Entity
@NoArgsConstructor(access = PROTECTED)
public class Pocket {
/* fields and other methods */
public void deleteTamagotchi(UUID tamagotchiId) {
Tamagotchi tamagotchi = tamagotchiById(tamagotchiId);
if (tamagothis.size() == 1) {
throw new TamagotchiDeleteException("Cannot delete Tamagotchi because it's the single one");
}
tamagotchis.remove(tamagotchi);
}
public static Pocket newPocket(String name) {
Pocket pocket = new Pocket();
pocket.setId(UUID.randomUUID());
pocket.setName(name);
pocket.createTamagotchi(new TamagotchiCreateRequest("Default")); // creating default tamagotchi
return pocket;
}
}
如您所见,在汇总中保证了不变性的正确性。即使您愿意,如果它是一个单个,也无法创建具有零Tamagotchi
或删除Tamagotchi
的Pocket
。我认为这很棒。代码易于错误,更容易维护。
每个tamagotchi名称都必须在口袋里唯一
要实现此要求,我们需要稍微更改createTamagotchi
和updateTamagotchi
方法。查看下面的代码示例:
@Entity
@NoArgsConstructor(access = PROTECTED)
public class Pocket {
/* fields and other methods */
public UUID createTamagotchi(TamagotchiCreateRequest request) {
Tamagotchi tamagotchi = Tamagotchi.newTamagotchi(request.name(), this);
tamagotchis.add(tamagotchi);
validateTamagotchiNamesUniqueness();
return tamagotchi.getId();
}
public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
Tamagotchi tamagotchi = tamagotchiById(tamagotchiId);
tamagotchi.changeName(request.name());
validateTamagotchiNamesUniqueness();
}
private void validateTamagotchiNamesUniqueness() {
Set<String> names = new HashSet<>();
for (Tamagotchi tamagotchi : tamagotchis) {
if (!names.add(tamagotchi.getName()) {
throw new TamagotchiNameInvalidException("Tamagotchi name is not unique: " + tamagotchi.getName());
}
}
}
}
您可能会注意到我为
Tamagotchi.name
字段添加了一个Getter。因为Tamagotchi
形成一个单个骨料,所以可以提供getters。因为Tamagotchi
不应从我知道
validateTamagotchiNamesUniqueness
表现不佳。不用担心,我们将在Performance implications
部分中讨论解决方法。
再次,域模型保证每个Tamagotchi
名称在Pocket
中都是唯一的。有趣的是,API没有改变。调用这些公共方法(可能域服务)的代码不必更改逻辑。
如果用户删除了tamagotchi,则可以按名称恢复它
这个很棘手,涉及软删除。它还有其他点:
- 如果已经存在具有相同名称的
Tamagotchi
,则用户无法还原其已删除的Tamagotchi
。 - 如果用户以相同名称删除了多个
Tamagotchis
,则只能还原最后一个。
我不喜欢软删除的粉丝,涉及添加many reasons的isDeleted
列。取而代之的是,我将介绍一个新的实体DeletedTamagotchi
,其中包含已删除的Tamagotchi
状态。查看下面的代码示例。
@Entity
@NoArgsConstructor(access = PROTECTED)
@Getter
class DeletedTamagotchi {
@Id
private UUID id;
private String name;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "pocket_id")
private Pocket pocket;
@Enumerated(STRING)
private Status status;
public static DeletedTamagotchi newDeletedTamagotchi(Tamagotchi tamagotchi) {
DeletedTamagotchi deletedTamagotchi = new DeletedTamagotchi();
deletedTamagotchi.setId(UUID.randomUUID());
deletedTamagotchi.setName(tamagotchi.getName());
deletedTamagotchi.setPocket(tamagotchi.getPocket());
deletedTamagotchi.setStatus(tamagotchi.getStatus());
return deletedTamagotchi;
}
}
Tamagotchi
实体非常简单,因此DeletedTamagotchi
包含相同的字段。但是,如果原始实体更加复杂,情况并非如此。例如,您可以将Tamagotchi
的状态保存在Map<String, Object>
字段中,该字段转换为JSONB in the database。
另外,DeletedTamagotchi
实体是像Tamagotchi
这样的包装私有化。因此,该实体的存在是实施细节。代码的其他部分不需要知道这一点,并直接与DeletedTamagotchi
进行交互。相反,最好提供单个方法Pocket.restoreTamagotchi
而无需其他详细信息。
现在,让我们将Pocket
实体更改为新要求。查看下面的代码示例:
@Entity
@NoArgsConstructor(access = PROTECTED)
public class Pocket {
/* fields and other methods */
@OneToMany(mappedBy = "pocket", cascade = PERSIST, orphanRemoval = true)
private List<DeletedTamagotchi> deletedTamagotchis = new ArrayList<>();
public void deleteTamagotchi(UUID tamagotchiId) {
Tamagotchi tamagotchi = tamagotchiById(tamagotchiId);
if (tamagothis.size() == 1) {
throw new TamagotchiDeleteException("Cannot delete Tamagotchi because it's the single one");
}
tamagotchis.remove(tamagotchi);
addDeletedTamagotchi(tamagotchi);
}
private void addDeletedTamagotchi(Tamagotchi tamagotchi) {
Iterator<DeletedTamagotchi> iterator = deletedTamagotchis.iterator();
// if Tamagotchi with the same has been deleted,
// remove information about it
while (iterator.hasNext()) {
DeletedTamagotchi deletedTamagotchi = iterator.next();
if (deletedTamagotchi.getName().equals(tamagotchi.getName()) {
iterator.remove();
break;
}
}
deletedTamagotchis.add(
newDeletedTamagotchi(tamagotchi)
);
}
public UUID restoreTamagotchi(String name) {
DeletedTamagotchi deletedTamagotchi = deletedTamagotchiByName(name);
return createTamagotchi(new TamagotchiCreateRequest(deletedTamagotchi.getName()));
}
}
deleteTamagotchi
方法还可以创建或替换DeletedTamagotchi
记录。这意味着由于任何原因出于任何原因而调用该方法的其他代码都不会违反有关软删除的新要求,因为它已在内部实现。
要执行所需的业务操作,您应该只调用Pocket.restoreTamagotchi
。我们隐藏了幕后所有复杂的细节。更好的是,DeletedTamagotchi
不是公共API的一部分。这意味着如果不需要的话,它可以很容易地修改甚至可以删除。
您可以看到,将业务逻辑置于汇总中有很大的好处。但是,这并不是故事的结尾。我们仍然需要解决一些问题。下一个是查询数据。
查询数据
当我们处理冬眠时,通常我们会使用公共Getters将实体转换为DTO并将其返回给用户。但是,现在只有Pocket
实体是公开的,并且没有提供任何Getters(除Pocket.getId()
外)。在这种情况下,我们如何执行查询?我可以建议几种方法。
手动查询
明显的解决方案只是编写常规的JPQL或SQL语句。 Hibernate使用反射,并且不需要公共场地。如果您从头开始启动项目,这可能会起作用。但是,如果您已经依靠Getters从实体中检索信息并将其放入DTO中,那么过渡可能是压倒性的。这就是为什么我们有第二种选择。
引入Todto方法
一个实体可以提供toDto
或类似的方法,将其内部表示形式返回作为单独的数据结构。它类似于Memento design pattern。查看下面的代码示例:
@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter(PRIVATE)
public class Pocket {
/* other fields and methods */
public PocketDto toDto() {
return new PocketDto(
id,
name,
tamagotchis.stream()
.map(Tamagotchi::toDto)
.toList()
);
}
}
@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter(PRIVATE)
@Getter
class Tamagotchi {
/* other fields and methods */
public TamagotchiDto toDto() {
return new TamagotchiDto(id, name, status);
}
}
返回的DTO是一个不可变的对象,不影响实体状态。此外,该方法也有助于单位测试。让我们继续前进。
单元测试实体
我们将测试以下情况:
-
Pocket
必须始终拥有至少一个Tamagotchi
。 - 如果删除
Tamagotchi
,则可以按名称还原。 - 如果您删除具有相同名称的多个
Tamagotchi
,则只能还原最后一个。
整个测试套件可由this link提供。
口袋必须始终拥有至少一个tamagotchi
查看下面的单元测试。
class PocketTest {
@Test
void shouldCreatePocketWithTamagotchi() {
Pocket pocket = Pocket.newPocket("My pocket");
PocketDto dto = pocket.toDto();
assertEquals(1, dto.tamagotchis().size());
}
@Test
void shouldForbidDeletionOfASingleTamagotchi() {
Pocket pocket = Pocket.newPocket("My pocket");
PocketDto dto = pocket.toDto();
UUID tamagotchiId = dto.tamagotchis().get(0).id();
assertThrows(
TamagotchiDeleteException.class,
() -> pocket.deleteTamagotchi(tamagotchiId)
);
}
}
第一个检查使用单个Tamagotchi
创建Pocket
。虽然第二个验证了您将无法删除Tamagotchi
,如果它是一个。
我喜欢这些测试的是它们是单位。没有数据库,没有测试范围,只有常规JUNIT,我们已经成功验证了业务逻辑。凉爽的!让我们前进。
如果删除tamagotchi,则可以按名称恢复它
这个更复杂。查看下面的代码示例。
class PocketTest {
@Test
void shouldDeleteTamagotchiById() {
Pocket pocket = Pocket.newPocket("My pocket");
UUID tamagotchiId = pocket.createTamagotchi(new TamagotchiCreateRequest("My tamagotchi", CREATED));
pocket.deleteTamagotchi(tamagotchiId);
PocketDto dto = pocket.toDto();
assertThat(dto.tamagotchis())
.noneMatch(t -> t.name().equals("My tamagotchi"));
}
@Test
void shouldRestoreTamagotchiById() {
Pocket pocket = Pocket.newPocket("My pocket");
UUID tamagotchiId = pocket.createTamagotchi(new TamagotchiCreateRequest("My tamagotchi", CREATED));
pocket.deleteTamagotchi(tamagotchiId);
pocket.restoreTamagotchi("My tamagotchi");
PocketDto dto = pocket.toDto();
assertThat(dto.tamagotchis())
.anyMatch(t -> t.name().equals("My tamagotchi"));
}
}
shouldDeleteTamagotchiById
检查删除是否可以按预期工作。另一个验证了restoreTamagotchi
方法行为。
如果删除具有相同名称的多个tamagotchi,则只能还原最后一个
这是最具挑战性的。查看下面的代码示例。
class PocketTest {
@Test
void shouldRestoreTheLastTamagotchi() {
Pocket pocket = Pocket.newPocket("My pocket");
UUID tamagotchiId = pocket.createTamagotchi(new TamagotchiCreateRequest("My tamagotchi", CREATED));
pocket.deleteTamagotchi(tamagotchiId);
tamagotchiId = pocket.createTamagotchi(new TamagotchiCreateRequest("My tamagotchi", PENDING));
pocket.deleteTamagotchi(tamagotchiId);
pocket.restoreTamagotchi("My tamagotchi");
PocketDto dto = pocket.toDto();
assertThat(dto.tamagotchis())
.anyMatch(t ->
t.name().equals("My tamagotchi")
&& t.status().equals(PENDING)
);
}
}
在这里我们执行以下步骤:
- 创建
Pocket
。 - 用
My tamagotchi
和状态CREATED
的名称创建Tamagotchi
。 - 删除
Tamagotchi
。 - 用
My tamagotchi
的名称和状态PENDING
创建Tamagotchi
。 - Restore
Tamagotchi
按名称My tamagotchi
。 - 验证最后一个
Tamagotchi
已恢复(具有PENDING
的状态)。
这是测试的运行结果:
丰富的域模型模式使我们能够通过简单的单元测试测试复杂的业务方案。我认为这很出色。但是,集成测试也很重要,因为我们需要将数据存储在DB中,而不是在RAM中。让我们讨论等式的这一部分。
集成测试
我们使用具有存储库(通常是弹簧数据)的实体。让我们写一些用例并测试它们:
- 创建
Pocket
。 - 创建
Tamagotchi
。 - 更新
Tamagotchi
。
整个测试套件可提供this link。
创建口袋
查看下面的服务示例:
@Service
@RequiredArgsConstructor
public class PocketService {
private final EntityManager em;
@Transactional
public UUID createPocket(String name) {
Pocket pocket = Pocket.newPocket(name);
em.persist(pocket);
return pocket.getId();
}
}
是时候编写一些集成测试了。查看下面的代码段:
@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
@Transactional(propagation = NOT_SUPPORTED)
@Import(PocketService.class)
class PocketServiceIntegrationTest {
@Container
@ServiceConnection
public static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:13");
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private TestEntityManager em;
@Autowired
private PocketService pocketService;
@BeforeEach
void cleanDatabase() {
// there is cascade constraint in the database deleting tamagotchis and deleted_tamagotchis
transactionTemplate.executeWithoutResult(
s -> em.getEntityManager().createQuery("DELETE FROM Pocket ").executeUpdate()
);
}
@Test
void shouldCreateNewPocket() {
UUID pocketId = pocketService.createPocket("New pocket");
PocketDto dto = transactionTemplate.execute(
s -> em.find(Pocket.class, pocketId).toDto()
);
assertEquals("New pocket", dto.name());
}
}
我使用TestContainers库在Docker中启动POSGTRESQL。 Flyway迁移工具在运行测试之前创建表。
您可以查看this link的迁移。
我猜这个片段并不那么复杂。所以,让我们下一步。
创建Tamagotchi
查看下面的代码服务实现:
@Service
@RequiredArgsConstructor
public class PocketService {
/* other fields and methods */
@Transactional
public UUID createTamagotchi(UUID pocketId, TamagotchiCreateRequest request) {
Pocket pocket = em.find(Pocket.class, pocketId);
return pocket.createTamagotchi(request);
}
}
您可以看到,富域模型模型需要将服务声明为易于理解和测试的薄层。这是测试本身:
/* same Java annotations */
class PocketServiceIntegrationTest {
/* initialization... */
@Test
void shouldCreateTamagotchi() {
UUID pocketId = pocketService.createPocket("New pocket");
UUID tamagotchiId = pocketService.createTamagotchi(
pocketId,
new TamagotchiCreateRequest("my tamagotchi", CREATED)
);
PocketDto dto = transactionTemplate.execute(
s -> em.find(Pocket.class, pocketId).toDto()
);
assertThat(dto.tamagotchis())
.anyMatch(t ->
t.name().equals("my tamagotchi")
&& t.status().equals(CREATED)
&& t.id().equals(tamagotchiId)
);
}
}
这个更有趣。首先,我们创建一个Pocket
,然后在其中添加一个Tamogotchi
。断言检查结果dto中是否存在预期的Tamagotchi
。
更新Tamagotchi
这是最吸引人的。查看下面的实现:
@Service
@RequiredArgsConstructor
public class PocketService {
/* other fields and methods */
@Transactional
public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
UUID pocketId = em.createQuery(
"SELECT t.pocket.id AS id FROM Tamagotchi t WHERE t.id = :tamagotchiId",
UUID.class
)
.setParameter("tamagotchiId", tamagotchiId)
.getSingleResult();
Pocket pocket = em.find(Pocket.class, pocketId);
pocket.updateTamagotchi(tamagotchiId, request);
}
}
API要求通过tamagotchiId
。但是域模型允许我们仅通过Pocket
更新Tamagotchi
,因为后者是骨料根。因此,我们确定pocketId
对DB的附加查询,然后通过其ID选择Pocket
。测试也很有趣:
/* same Java annotations */
class PocketServiceIntegrationTest {
/* other fields and methods */
@Test
void shouldUpdateTamagotchi() {
UUID pocketId = pocketService.createPocket("New pocket");
UUID tamagotchiId = pocketService.createTamagotchi(
pocketId,
new TamagotchiCreateRequest("my tamagotchi", CREATED)
);
pocketService.updateTamagotchi(
tamagotchiId,
new TamagotchiUpdateRequest("another tamagotchi", PENDING)
);
PocketDto dto = transactionTemplate.execute(
s -> em.find(Pocket.class, pocketId).toDto()
);
assertThat(dto.tamagotchis())
.anyMatch(t ->
t.name().equals("another tamagotchi")
&& t.status().equals(PENDING)
&& t.id().equals(tamagotchiId)
);
}
}
步骤是:
- 创建
Pocket
。 - 创建
Tamagotchi
。 - 更新
Tamagotchi
。 - 验证结果dto。
这是所有集成测试的执行结果:
没什么复杂的,你不认为吗?
绩效影响
丰富的域模型肯定会使头顶上方。但是,有
一些解决方法可以实现妥协。
查询的优化
首先,让我们再次查看PocketService.updateTamagotchi
方法:
@Service
@RequiredArgsConstructor
public class PocketService {
/* other fields and methods */
@Transactional
public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
UUID pocketId = em.createQuery(
"SELECT t.pocket.id AS id FROM Tamagotchi t WHERE t.id = :tamagotchiId",
UUID.class
)
.setParameter("tamagotchiId", tamagotchiId)
.getSingleResult();
Pocket pocket = em.find(Pocket.class, pocketId);
pocket.updateTamagotchi(tamagotchiId, request);
}
}
问题在于,当我们实际想更新单个单个时,我们为指定的Pocket
检索所有现有的Tamagotchi
实例。查看下面的日志:
select t1_0.pocket_id from tamagotchi t1_0 where t1_0.id=?
select p1_0.id,p1_0.name from pocket p1_0 where p1_0.id=?
select t1_0.pocket_id,t1_0.id,t1_0.name,t1_0.status
from tamagotchi t1_0 where t1_0.pocket_id=?
我们可以更改查询以限制不必要数据的传输。查看下面的代码示例:
@Service
@RequiredArgsConstructor
public class PocketService {
/* other fields and methods */
@Transactional
public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
Pocket pocket = em.createQuery(
"""
SELECT p FROM Pocket p
LEFT JOIN FETCH p.tamagotchis t
WHERE t.id = :tamagotchiId
""",
Pocket.class
).setParameter("tamagotchiId", tamagotchiId)
.getSingleResult();
pocket.updateTamagotchi(tamagotchiId, request);
}
}
我们没有为指定的Pocket
选择所有现有的Tamagotchi
实例,而是通过指定的ID检索Pocket
和唯一关联的Tamagotchi
实例。日志看起来也有所不同:
select
p1_0.id,
p1_0.name,
t1_0.pocket_id,
t1_0.id,
t1_0.name,
t1_0.status
from pocket p1_0
left join tamagotchi t1_0 on p1_0.id=t1_0.pocket_id
where t1_0.id=?
即使Pocket
包含数千个Tamagotchi
,也不会影响应用程序的性能。因为它只会检索一个。如果您从上一段中运行测试用例,它们也将成功通过。
精确指出优化的检查
然而,以前的技术有局限性。要理解这一点,让我们写另一个测试。正如我们已经讨论的那样,业务规则要求每个Tamagotchi
在Pocket
中必须具有唯一的名称。让我们测试这种行为。查看下面的代码段:
@Test
void shouldUpdateTamagotchiIfThereAreMultipleOnes() {
UUID pocketId = pocketService.createPocket("New pocket");
UUID tamagotchiId = pocketService.createTamagotchi(
pocketId,
new TamagotchiCreateRequest("Cat", CREATED)
);
pocketService.createTamagotchi(
pocketId,
new TamagotchiCreateRequest("Dog", CREATED)
);
assertThrows(
TamagotchiNameInvalidException.class,
() -> pocketService.updateTamagotchi(tamagotchiId, new TamagotchiUpdateRequest("Dog", CREATED))
);
}
有两个Tamagotchi
,名称为Cat
和Dog
。我们尝试将Cat
重命名为Dog
。在这里,我们希望获得TamagotchiNameInvalidException
。因为业务规则应验证这种情况。但是,如果您进行测试,则会得到此结果:
Expected com.example.demo.domain.exception.TamagotchiNameInvalidException to be thrown, but nothing was thrown.
为什么?再次查看Pocket.updateTamagotchi
方法声明:
public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
Tamagotchi tamagotchi = tamagotchiById(tamagotchiId);
tamagotchi.changeName(request.name());
tamagotchi.changeStatus(request.status());
validateTamagotchiNamesUniqueness();
}
private void validateTamagotchiNamesUniqueness() {
Set<String> names = new HashSet<>();
for (Tamagotchi tamagotchi : tamagotchis) {
if (!names.add(tamagotchi.getName())) {
throw new TamagotchiNameInvalidException(
"Tamagotchi name is not unique: " + tamagotchi.getName());
}
}
}
您可以看到,Pocket
Excregate 期望可以访问所有Tamagotchi
以验证业务规则。但是我们更改了查询,只选择了一个Tamagotchi
(我们要更新的Tamagotchi
)。这就是为什么没有提出例外。因为列表中总是有一个Tamagotchi
,我们不能违反独特性。
我看到人们试图从聚合中删除此类验证。但是我认为你不应该那样做。相反,最好先在服务级别进行另一个优化的检查。要了解这种方法,请查看以下模式:
聚合应始终有效。您可以预测所有可能的未来结果。也许您会在另一种情况下致电Pocket
。因此,如果您完全从总体上放下支票,则可能会意外违反商业规则。
尽管如此,我们生活在表演很重要的现实世界中。最好执行单个exists
SQL语句,然后从数据库中检索所有Tamagotchi
实例。因此,您将优化的检查专门在需要的位置。但是您也将汇总不触及。
查看PocketService.updateTamagotchi
方法的最终代码段:
@Transactional
public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
boolean nameIsNotUnique = em.createQuery(
"""
SELECT COUNT(t) > 0 FROM Tamagotchi t
WHERE t.id <> :tamagotchiId AND t.name = :newName
""",
boolean.class
).setParameter("tamagotchiId", tamagotchiId)
.setParameter("newName", request.name())
.getSingleResult();
if (nameIsNotUnique) {
throw new TamagotchiNameInvalidException("Tamagotchi name is not unique: " + request.name());
}
UUID pocketId = em.createQuery(
"SELECT t.pocket.id AS id FROM Tamagotchi t WHERE t.id = :tamagotchiId",
UUID.class
)
.setParameter("tamagotchiId", tamagotchiId)
.getSingleResult();
Pocket pocket = em.find(Pocket.class, pocketId);
pocket.updateTamagotchi(tamagotchiId, request);
}
首先,我们检查其他任何Tamagotchi
(除了要更新的Tamagotchi
)是否具有相同的名称。如果那是真的,我们会引发一个例外。如果您再次运行上一个测试并检查日志,则会发现仅调用了COUNT
查询:
select count(t1_0.id)>0
from tamagotchi t1_0
where t1_0.id!=? and t1_0.name=?
无论如何,我不建议您过度使用这种方法。您应该像精确固定的补丁一样对待它。换句话说,仅在需要的地方说。否则,我更喜欢依靠域逻辑并将代码留在服务中尽可能简单。
数据库生成的ID
以前我提到过,我会向您展示客户端生成的ID的示例。但是,有时我们想使用其他ID类型。例如,sequence generated ones。这种丰富的域模型模式也适用于这些ID类型吗?是的,但也有一些担忧。
首先,使用IDENTITY generation strategy查看Pocket
和Tamagotchi
实体:
@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter(PRIVATE)
public class Pocket {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
/* other fields aren't important */
public Long createTamagotchi(TamagotchiCreateRequest request) {
Tamagotchi newTamagotchi = Tamagotchi.newTamagotchi(request.name(), request.status(), this);
tamagotchis.add(newTamagotchi);
validateTamagotchiNamesUniqueness();
// always returns null
return newTamagotchi.getId();
}
/* other methods aren't important */
public static Pocket newPocket(String name) {
Pocket pocket = new Pocket();
pocket.setName(name);
pocket.createTamagotchi(new TamagotchiCreateRequest("Default", CREATED));
return pocket;
}
}
@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter(PRIVATE)
@Getter
class Tamagotchi {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
/* other fields and methods aren't important */
public static Tamagotchi newTamagotchi(String name, Status status, Pocket pocket) {
Tamagotchi tamagotchi = new Tamagotchi();
tamagotchi.setName(name);
tamagotchi.setPocket(pocket);
tamagotchi.setStatus(status);
return tamagotchi;
}
}
您可以看到,我们不再直接分配ID。取而代之的是,我们以null
值离开场地,然后稍后再填充它。不幸的是,该决定打破了Pocket.createTamagotchi
方法的逻辑。在创建Tamagotchi
对象期间,我们没有设置ID。因此,Tamagotchi.getId
的调用始终返回null
(直到您flush更改为数据库)。
有几种解决此问题的方法。
手动填写ID
您可以消除@GeneratedValue
注释用法并直接传递构造函数中的ID值。在这种情况下,您必须调用SELECT nextval('mysequence')
语句并将其结果传递给实体。查看下面的代码示例:
@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter(PRIVATE)
public class Pocket {
@Id
private Long id;
/* other fields aren't important */
public Long createTamagotchi(long tamagotchiId, TamagotchiCreateRequest request) {
Tamagotchi newTamagotchi = Tamagotchi.newTamagotchi(tamagotchiId, request.name(), request.status(), this);
tamagotchis.add(newTamagotchi);
validateTamagotchiNamesUniqueness();
// always returns null
return newTamagotchi.getId();
}
/* other methods aren't important */
public static Pocket newPocket(long id, String name) {
Pocket pocket = new Pocket();
pocket.setId(id);
pocket.setName(name);
pocket.createTamagotchi(new TamagotchiCreateRequest("Default", CREATED));
return pocket;
}
}
@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter(PRIVATE)
@Getter
class Tamagotchi {
@Id
private Long id;
/* other fields and methods aren't important */
public static Tamagotchi newTamagotchi(long id, String name, Status status, Pocket pocket) {
Tamagotchi tamagotchi = new Tamagotchi();
tamagotchi.setId(id);
tamagotchi.setName(name);
tamagotchi.setPocket(pocket);
tamagotchi.setStatus(status);
return tamagotchi;
}
}
优点是您的实体课程不依赖某些冬眠魔法,您仍然可以通过定期单位测试来验证业务案例。但是,您还可以使代码更详细。因为您可以手动通过ID。
无论如何,这种方法值得考虑。
我在this article中找到了此选项。实际上,作者要求完全停止使用冬眠。即使我喜欢冬眠,我也发现了一些有趣的论点。
介绍业务密钥
有时手动传递ID几乎是不可能的。也许它需要太多无法忍受的重构。也许您的应用程序可与mysql一起使用。
doesn't support sequences but only auto increment columns尽管您可以通过创建常规表来模拟MySQL中的序列,但此方法表现不佳。
在这种情况下,您可以引入business key。这是一个单独的值,可以唯一地识别实体。尽管这并不意味着业务密钥必须在全球范围内独一无二。例如,如果您指向name
的Tamagotchi
,并且它仅在Pocket
中唯一,则可以通过(pocket_business_key, tamagothic_name)
的组合来识别Tamagotchi
。
尽管如此,每个业务密钥都应 。否则,您可以妥协实体的身份。因此,请很好地注意这一点。
另外,业务密钥的一个很好的例子是slug。查看本文的URL。您是否看到它包含其名称和一些哈希值?那就是sl。它仅在创建文章时只分配一次,但从未更改(即使我更改了文章的名称)。因此,如果您的实体没有明显的业务密钥候选人,则可能是一个选择。
丰富的领域模型总是值得吗?
软件开发中没有最终决定。每种方法都是妥协。丰富的域模型也不例外。
我通过向您解释贫血领域模型的问题开始了我的文章。它们都有效并有意义。但这并不意味着丰富的领域模型没有缺点。我可以想到这些:
- 如果您使用Hibernate,那么丰富的域模型模式并不那么受欢迎。这只是现实。互联网上有数十种文章,其中有冬眠的例子,并且完全没有丰富的领域模型。人们习惯了贫血领域的模型,您必须考虑到它。
- 丰富的领域模型模式也可能带来一些绩效惩罚。其中一些很容易固定。但是其他人可能会头疼。如果您的应用程序应该是高负载,则必须确保不变的检查不会太大的响应时间。
- 丰富的域模型使用通常会导致god object entities。当然,这会使维护更加困难。有一些方法可以解决。例如,Vaughn Vernon wrote 3 articles about effective aggregate design。但是,如果您的实体已经是上帝的对象,那么很难重构。
结论
最后,我可以说,我认为丰富的领域模型比贫血的模型更好。但是不要盲目应用。您还应该考虑可能的后果并明智地做出决定。
非常感谢您阅读这篇文章。我希望你学到了一些新东西。如果您觉得很有趣,请与您的朋友和同事分享,按“喜欢”按钮,然后在下面放下您的评论。我很高兴听到您的意见并讨论问题。祝你有美好的一天!
资源
- Hibernate
- Stop Using JPA/Hibernate
- Anemic Domain Model pattern
- Status quo
- Rich Domain Model pattern
- The entire repository with code examples
- Open-Closed principle
- Validation VS invariants
- Saga pattern
- Encapsulation (computer programming)
- Lombok library
- @Transactional annotation
- Spaghetti code
- Mockito library
- Tescontainers library
- Flaky tests
- My article about Unit Testing
- Tactical DDD
- DDD Aggregate
- Why does JPA require No-Args Constructor for Domain Objects
- What does it mean to write a buggy code
- Are soft deletes a good idea?
- PostgreSQL, Datatype JSON
- JPQL
- Memento design pattern
- Flyway migration tool
- PostgreSQL, create sequences
- Hibernate, IDENTITY generation strategy
- Auto increment columns in MySQL
- Surrogate key VS natural key differences
- God object
- Effective aggregate design by Vaughn Vernon
- JPA flush