In typical server applications, the relationships between entities are often expressed through database foreign keys and JPA associations. This approach is powerful; it prevents the saving of children without parents, propagates the deletion of children when a parent is deleted, and allows for saving and deleting to be handled together following the object graph.
However, the changes in relationships in the actual domain are broader than simple repository-level relationship handling. When an entity is deleted, another entity may need to be deleted as well, but in some cases, it may be necessary to create an empty parent first, create sibling entities together, or recalculate connected values. This article will summarize why we want to handle relationships at the application level and how we can complement this based on the JPO structure of the Vizend basic project.
1. Keeping the JPO Concise
The JPO of the Vizend basic project is generally concise. JPOs are JPA Entities, but they are closer to a storage model for persisting domain objects rather than an ORM model that richly expresses relationships. In other words, the JPO focuses on saving a single row and converting it back into a domain object rather than encompassing the entire object graph of the domain.
In a typical JPA model, parents and children can be linked via object references, with parents owning a persistent collection of children. However, the basic model of Vizend has children holding the parent ID as @FieldSourceId, and the parent holds the list of children as transient. The JPO saves this structure in a simple field-centric manner without any relationship mapping.
class Board {
private String id;
private String name;
private transient List<Card> cards;
}
class Card {
private String id;
@FieldSourceId
private String boardId;
private String title;
private transient List<Comment> comments;
}
class Comment {
private String id;
@FieldSourceId
private String cardId;
}
The intent of this structure is to keep the storage model simple and to show an approach where relationships are interpreted and handled in application code rather than heavily relying on database-level relationship handling.
2. Why Relationships Are Handled by Applications, Not Repositories
One of the biggest reasons is that the DB vendor and operating environment are not fixed. Basic projects or code-generation-based projects may not have a definitive database that they will run on from the beginning. While foreign key constraints are supported by most RDBMS, the way the constraints are generated, the specifics of reference actions, DDL auto-generation, migration strategies, and trigger usage vary depending on the database and operating environment.
If domain rules heavily depend on repository-level relationship handling, the options for the project's database become limited. Designing domain rules based on the constraints, triggers, and cascade action behaviors of a specific database can lead to more significant differences than expected when moving to another database. Therefore, it is beneficial for the basic model to maintain simple ID references to allow application code to interpret relationship rules, rather than relying on specific DB features.
The second reason is that relationship changes are broader than deletion propagation. Repository-level relationship handling is strong in preventing the saving of children without parents or deleting children together when a parent is deleted. However, the actual domain rules do not stop here.
For example, in cases where B should be deleted when A is deleted, this can be expressed to some extent with repository-level reference actions or JPA remove cascades. However, in situations where A needs to be created first because the parent A is not present when saving B, or when A must be created alongside sibling B, or when A's value needs to be modified or deleted to recalculate B's value, simple foreign key actions are difficult to express.
Using database triggers can move some processing inside the DB. However, doing so hides domain logic inside the database rather than in application code. It becomes difficult to test and trace, and separating service boundaries becomes challenging. As the application grows, relationship changes become more aligned with use cases and domain rules than with automatic repository actions.
3. Issues Arising When Solving with Application Code
By handling relationships in application code, domain rules can be expressed more explicitly. The logic to delete Cards when a Board is deleted, and to delete Comments when a Card is deleted is clear in the code. Domain behaviors such as not just deletion, but also history recording, state validation, permission checks, and projection updates can also be included.
The problem is that the relationship handling code tends to become increasingly scattered. Initially, only the Card needs to be deleted when the Board is deleted. Later, after Comments are created under the Card, the Comment deletion logic is added to the Card deletion logic. As concepts like NotificationRule, DashboardWidget, and ActivityLog emerge, the deletion propagation code grows larger.
The first problem is omission. Even if a new reference field is added, the existing deletion logic cannot be notified automatically. The developer must remember that relationship and reflect it in the deletion logic. If omitted, orphan data will remain.
The second problem is multiple paths. If there are paths from A to C through B and from A to C through D simultaneously, C can be reached twice during the deletion process of A. Deletion should occur only once, but if each handler or action independently calls subsequent deletions, there is a risk of calling the same C deletion twice.
A -> B -> C
A -> D -> C
The third problem is the difficulty of understanding the impact scope. Before deleting A, it is hard to know which entities will be affected together. If relationship handling is at the repository level, the DB or JPA will perform the defined rules, but in application code cascade, the processing paths are scattered across multiple actions and handlers.
These problems arise not because repository-level relationship handling was not used, but because the propagation of relationships was handled in the application code without consistently managing the execution flow.
4. Direction for resolution: DataEvent and CascadeContext
Divided into Flow, Logic, PolicyHandler, and CascadeContext, making each role separate and perform its duties.
Flow creates execution context
Logic performs its own CUD
Logic publishes DataEvent
PolicyHandler reacts to DataEvent
CascadeContext prevents duplicate actions
Flow is the entry point of the use case. Here, a single execution scope is created, and a CascadeContext is generated. Logic performs actual CQRS. It creates, modifies, and deletes its entity and publishes DataEvent. PolicyHandler receives the DataEvent and calls the associated Logic.
For example, when a request to delete a Board comes in, Flow creates the context and calls BoardLogic. BoardLogic is only responsible for the deletion of the Board and the publication of the BoardRemoved event. Upon receiving the BoardRemoved event, BoardPolicyHandler finds the Card list and calls CardLogic. Then CardLogic publishes the CardRemoved event, and CardPolicyHandler calls CommentLogic.
In this structure, each Logic handles only its own. BoardLogic does not know about Cards. CardLogic does not know about Comments. The PolicyHandler is in charge of relationship propagation.
The most cautionary point in event-based propagation is duplicate processing. The same entity can be reached through multiple paths. To prevent this, CascadeContext is provided to remember the actions that have already been processed within a single execution flow.
public class CascadeContext {
private final Set<String> completed = new HashSet<>();
public boolean enter(Class<?> type, String id, String action) {
String key = type.getName() + ":" + id + ":" + action;
return completed.add(key);
}
}
PolicyHandler checks the context before calling the next Logic. If the action has already been processed, it skips it, and if it is the first encountered action, it calls the next Logic.
public class BoardPolicyHandler {
public void onBoardRemoved(DataEvent event) {
CascadeContext context = cascadeContextHolder.current();
List<Card> cards = cardStore.findByBoardId(event.entityId());
for (Card card : cards) {
if (!context.enter(Card.class, card.getId(), "REMOVE")) {
continue;
}
cardLogic.removeCard(card.getId());
}
}
}
This way, even if there is a structure where C is reached from A through B and from A through D, C will be processed only once. If the same action comes in again, context.enter will return false, and the handler will move on to the next.
When applying this structure, it must be made clear that Spring events are executed synchronously. To propagate within the same transaction, the general synchronous EventListener flow must be used. Using asynchronous events or events after commit makes it difficult to maintain the same CascadeContext and transaction.
5. Observing relationships with metadata
CascadeContext prevents execution duplication. However, to know what relationships exist, what relationships are being processed, and what relationships are missing, a separate observability is needed. Here, FieldSourceId and RelationPolicy can be used together.
FieldSourceId can be used to discover reference relationships. For example, if FieldSourceId is attached to Card.boardId, the application will know that the Card references the Board. By scanning this information, a reference graph can be created.
ReferenceGraph
Board
Card.boardId
Card
Comment.cardId
However, the reference graph alone cannot tell us how the parent's CUD affects the children. When the Board is deleted, whether to delete the Card, clear Card.boardId, or prevent the deletion is a separate policy.
To achieve this, we can have metadata like RelationPolicy in the methods of PolicyHandler. This annotation does not substitute the behavior of the handler. The actual behavior remains in the method body. The annotation serves as metadata to indicate what kind of relationship propagation this handler is responsible for.
@RelationPolicy(
source = Board.class,
event = DataEventType.REMOVED,
target = Card.class
)
public void onBoardRemoved(DataEvent event) {
List<Card> cards = cardStore.findByBoardId(event.entityId());
for (Card card : cards) {
cardLogic.removeCard(card.getId());
}
}
This way, the application can have two types of graphs. The reference graph shows who references whom. The propagation graph indicates what events affect which targets.
ReferenceGraph
FieldSourceId based graph
PropagationGraph
RelationPolicy based graph
By comparing the two graphs, we can examine the scope of influence, missing policies, duplicate paths, and circular paths. For example, if there is a relationship from Card to NotificationRule in the reference graph, but there is no NotificationRule policy for the CardRemoved event in the propagation graph, it can be seen as a missing policy. Conversely, if there are paths from A to C through B and from A to C through D, we can pre-warn about multiple paths during graph analysis.
Impact
Missing policy
Duplicate path
Cycle
The advantage of this structure is that it achieves observability in relationship handling without hiding domain logic in annotations. FieldSourceId is reference metadata, RelationPolicy is propagation metadata, and the actual domain reactions remain in the method body of PolicyHandler.
Finally, this structure can be extended to a scope of influence preview with a dry-run. By adding a dryRun flag and an impact list to CascadeContext, when Logic is in a dry-run state, we can omit only the actual repository changes while still publishing DataEvents. This allows tracking a chain of events similar to the actual execution while avoiding DB changes and calculating which entities are impacted. However, dry-run can only be trusted if it is assumed that all DataEvent handlers follow the same CascadeContext rules, so it is safer to cautiously introduce it starting from the areas where the influence scope is controlled.
6. Conclusion
Database foreign keys and JPA cascades are powerful tools for managing relationships. However, not all relationship changes are explained by lifecycle propagation at the repository level. As applications grow, relationship changes extend beyond deletion propagation to include domain rules such as creation corrections, sibling creations, recalculations, and projection updates.
The way Vizend's base JPO remains concise, with children holding the parent ID as FieldSourceId and assembling the parent's child list as transient, can be understood in this context. It is a choice to leave room for handling relationships in application code without strongly delegating to DB/JPA.
Of course, this choice comes with responsibilities. The application must directly manage the integrity that the database protected and prevent propagation omissions or duplicate executions. To achieve this, we discussed the roles of Flow, Logic, PolicyHandler, and CascadeContext, as well as the structure of observing relationships with FieldSourceId and RelationPolicy.
Logic changes only its own entity
Logic publishes DataEvent
PolicyHandler reacts to DataEvent
CascadeContext prevents duplicate actions
FieldSourceId describes reference graph
RelationPolicy describes propagation graph
This structure is not aimed at merely mimicking repository-level relationship handling in application code, but rather an attempt to leave the meaning of relationship changes within the domain code while managing the execution flow consistently.
Thank you for reading.
HHkk