The pressure of schedules and the realistic pitfalls of domain design
When starting a new project, many developers intellectually understand the value of Domain-Driven Design (DDD) but often opt for pragmatic compromises. This is especially true under the pressure of deadlines that demand a rapid business launch, making it difficult to spend a long time on sophisticated domain modeling.
The on-site personnel management platform project also attempted to write business code based on DDD principles from the early stages of development. However, due to the tight development schedule, we ended up missing some architecturally important key links while quickly designing the domain.
The design limitations we experienced at the time were largely threefold.
-
Lack of domain aggregate settingThe individual domain objects have not clearly defined what lifecycle and transaction scope they should share.
-
Indiscriminate Entityization of Value ObjectsValues objects that do not require identifiers and should simply be expressed as bundles of attributes have been designed as independent entities without any thought.
-
Missing relationship setup between domainsThe objects could not establish explicit relationships at the domain level regarding how they are connected and operate with each other.
As business rules were added in this state, the complexity of the system began to rise sharply. Since the relationships between domains were not declared in the code, the service layer (Domain Service) had to manually restore the relationships in the code by directly querying the scattered entities each time it performed business logic. Naturally, the service logic became bloated, and it became difficult to validate consistency.
We decided to take a step back and correct this. We would like to share the core process of the refactoring we conducted to address the technical debt left behind under the pressure of deadlines and to regain the autonomy of the domain.
Establishing Terms and Concepts: Ubiquitous Language in Code
The first step of DDD is establishing a ubiquitous language that allows all participants in the project, including planners, designers, and developers, to communicate in a shared language. The project code before refactoring used technically convenient terms and ambiguous words as names for domain objects.
'From the word of technology' to 'the word of work'
A typical example was the object representing the action of a worker applying for a job posting. In the existing code, it was simply named Application. While this was a very natural term from a developer's perspective, it had the following issues.
-
Application was confused with technical terms that refer to the framework or the application itself.
-
The term actually used in the labor supply business was closer to 'Enrollment', which indicates an intention to register and work on-site.
During the refactoring process, we changed this to the domain name Enrollment. We also improved the naming from a business perspective by excluding the technology-centric term Snapshot, which was used for the backup object ApplicationWorkerProfileSnapshot created temporarily during the application review process, to intuitive names like EnrollmentProfile and SubmittedDocument.
|
[Before] Technology-centric naming JobApplication ──> ApplicationWorkerProfileSnapshot ──> ApplicationDocumentSnapshot [After] Business (ubiquitous language) centric naming Enrollment ──> EnrollmentProfile ──> SubmittedDocument |
|---|
Developers no longer needed to translate the flow of the planning document in their heads while coding. Reading the code itself became equivalent to reading the business scenario. When looking at the domain model, I understood the architecture as one where the business scene could be imagined as it is, which is the meaning of ubiquitous language.
Defining Aggregate boundaries: Overcoming indiscriminate entity creation
The biggest debt in the initial design was 'objects designed as independent entities indiscriminately'. In object-oriented design and DDD, entities are objects that must have a unique identifier (Identity) and be tracked throughout their lifecycle. In contrast, value objects (Value Object / Value Group) simply represent attributes without an identifier and depend on the lifecycle of the parent object.
Rushed by the schedule, everything was implemented as entity tables, resulting in each entity having its own independent table, repository, and logic. This led to an excessive use of database joins and a dramatic increase in the cost of maintaining consistency.
JobPost and JobPostRecruitment case
A typical example was JobPost, which contained job posting information, and JobRecruitment, which indicated the specific number of positions and unit price for each job category within that posting. Originally, these were completely dependent sub-concepts to a single posting, but in the initial design, they were implemented as independent StageEntities. This caused inefficiencies as follows.
-
When modifying a job posting, to change the recruitment condition data, it was necessary to separately retrieve and modify the JobRecruitment entity within the transaction.
-
Despite the alignment of the announcement and recruitment conditions' lifecycle, there was a risk of inconsistency due to the external access to modify internal data directly.
During the refactoring process, we demoted JobRecruitment from an independent entity to a ValueGroup, which is a group of value objects without identifiers. This was then fully embedded within the JobPost aggregate root.
Now, additions, modifications, and deletions of recruitment conditions occur solely through the parent JobPost. By designing it so that individual recruitment conditions cannot be arbitrarily manipulated from the outside, the business rule "the number of recruits can only be modified when the recruitment announcement is open" can be perfectly guaranteed atomically inside JobPost.
Setting up relationships between domains and slimming down the service layer
Due to the lack of clear organic associations between domain entities in the initial design phase, significant inefficiencies existed in the service layer (Domain Service) that processes business logic.
|
[Initial Service Logic Structure] 1. Query entity A by ID 2. In situations requiring B entity's foreign identifier, but without a relationship, directly extract a specific field from entity A 3. Query and retrieve B entity separately from the database based on the extracted field value 4. Likewise, manually assemble C entity to process the logic |
|---|
If domain relationships (such as foreign keys and associated field conventions like @FieldSourceId) are not organic, entities tend to scatter like independent grains of sand. In this state, the service layer had to undertake arduous tasks to complete the logic.
Without domain relationships, the service logic had to manually restore relationships each time, which ultimately led to the expansion and decreased readability of the service layer. The observation that "if the logic exceeds 30 lines, the domain design is flawed" stemmed from this manual relationship restoration code.
Transitioning to relationship setup and a rich domain model
We have clearly re-established foreign reference identifiers and relationships between domains at the model level. We defined the foreign key relationships associated with each entity and specified the necessary subordinate associations within the entity. Furthermore, to prevent indiscriminate changes to fields from outside the entity, the abuse of setters was blocked, and modifications to the object's state were encapsulated to occur only through a clearly defined behavior method called modifyAttributes.
As the domain came to possess clear relationships and validate its own correctness while changing its state, the service layer no longer needed to play the role of a "coordinator that collects and assembles data." The service code could dramatically slim down to just pulling the root entity from the persistence context to direct business actions and act as an orchestrator that publishes events.
Injecting life into the domain through code
The domain refactoring process in this project was not a simple housekeeping task of just changing database column names or modifying package structures. It was a "shift in the perspective of how the system views the business."
When rushing quickly under tight schedules, accumulating design debt is perhaps inevitable. However, if this is neglected and the service grows large, the architecture will ultimately become paralyzed. Through this refactoring, I was able to directly experience the powerful benefits brought about by separating value objects, designing the correct aggregates, and establishing clear relationships between domains.
-
Efficiency of maintenance: When new business rules are added or policies are changed, there is no longer a need to ponder which object's code needs to be modified. This is because you can simply find the aggregate that owns the rules and modify only the validation logic within it.
-
Alignment in communication: Miscommunication between planners and developers has significantly decreased. As the flow in the planning document directly maps to the domain class diagram 1:1, the communication costs that were wasted on interpreting different languages have disappeared.
-
Predictability and stability: Unexpected side effects have been completely blocked through encapsulation, strict ID design, and aggregate isolation. The system has become much more predictable and robust.
Even if you rush for the schedule, taking a moment to pause and tidy up the domain will make future project speeds several times faster. I strongly recommend fellow developers who are contemplating design to experience this journey of structuring haphazardly generated entities into value objects and aggregates, and correcting the relationships between domains.
informalife