0%
December 29, 2024

JPA and DDD Notes from Real Project Experience

DDD

jpa

springboot

Json Fields

Json Column in JPA
    @Column(name = "event", nullable = false, columnDefinition = "jsonb")
    @JdbcTypeCode(SqlTypes.JSON)
    var event: JsonNode
JsonUtil that converts data class into JsonNode
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.KotlinModule

object JsonNodeUtil {
    private val objectMapper = ObjectMapper().apply {
        registerModule(KotlinModule.Builder().build())
        configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
    }

    fun <T> toJsonNode(value: T): JsonNode {
        return try {
            when (value) {
                is JsonNode -> value  // If already JsonNode, return as is
                is String -> objectMapper.readTree(value)  // If String, parse directly
                else -> objectMapper.valueToTree(value)    // For other types, convert directly
            }
        } catch (e: Exception) {
            throw Exception("Could not convert to JsonNode: ${e.message}", e)
        }
    }
}

Auto-Generated Field whose value comes from Pre-defined SQL Function

Simply add @Generated to the @Column-annotated field with import

import org.hibernate.annotations.Generated

We get the database-generated value after repository.save(eneity).

Rollback for any kind of Exception

Let's study the following case:

// some ApplicationService
@Transactional(rollbackOn = [Exception::class])
fun moveClass(reqBody: MoveClassRequest) {
    val (classId, toDayTimestamp, toHourTimestamp) = reqBody
    val cls = classRepository.findByIdOrNull(classId) ?: throw Exception("Class not found")
    val studentId = cls.studentPackage?.student?.id ?: throw Exception("Student not found")
    val student = studentRepository.findByIdOrNull(studentId) ?: throw Exception("Student not found")
    student.moveClass(classId, toDayTimestamp.toDouble(), toHourTimestamp.toDouble())
}

where

class SomeDomainModel {
    fun moveClass(classId: Int, toDayTimestamp: Double, toHourTimestamp: Double) {
        val allClasses = mutableSetOf<Class>()

        for (studentPackage in this.studentPackages) {
            val classes = studentPackage.classes
            allClasses += classes
        }

        val targetClassToMove = allClasses.find { it.id == classId } ?: throw Exception("Class not found")

        targetClassToMove.dayUnixTimestamp = toDayTimestamp
        targetClassToMove.hourUnixTimestamp = toHourTimestamp

        val targetClassAsList = listOf(targetClassToMove)

        for (studentPackage in this.studentPackages) {
            val packageValidation = studentPackage.Validation()
            packageValidation.`target class (possibly from other package) should not have time conflict with current package`(targetClassToMove)
            packageValidation.`should not create classes that are in the past`(targetClassAsList)
        }
    }
}
  • Without @Transactional(rollbackOn = [Exception::class]) since we updated targetClassToMove before all those validations, the dirty-checking mechanism of JPA (which tracks the changes and generate SQL) will make a persistent update to the class entity model.

  • It is because by default JPA only rollbacks on RuntimeException or on its subclasses such as IllegalArgumentException.

  • Having put @Transactional(rollbackOn = [Exception::class]) we are safe from dirty record.

The terms Domain Object and Aggregate Root in JPA

In Other Languages
  • In other languages aggregate root should always use a list of ids to reference its internal member:

    // C#
    public class Order
    {
        private readonly HashSet<OrderLineId> _orderLineIds;
    }
    // Golang
    type Order struct {
        OrderLineIDs []string  // just IDs
    }

    and classes without further reference to other entities are simply domain object.

  • Both aggregate root and domain object have behaviour.

Things are Different with JPA

But jpa is particularly well-established to implementing DDD principle, in aggregates our id's are automatically binded with the asscoiated entities automatically:

class StudentPackage() {
    @OneToMany
    @Cascade(CascadeType.ALL)
    @JoinTable(
        name = "rel_class_studentpackage",
        joinColumns = [JoinColumn(name = "student_package_id", referencedColumnName = "id")],
        inverseJoinColumns = [JoinColumn(name = "class_id", referencedColumnName = "id")]
    )
    var classes: MutableSet<Class> = mutableSetOf()
}

In jpa there is no technical code difference between Aggregate Root and Domain Object. The term is more about how the business model group things together.

Whether or not to expose a method to mutate the aggregate member directly depends on whether or not you expose (implement) that method only in root level.

When will DomainEvent of an AbstractAggregateRoot be Dispatched?

Consider an abstract aggregate root:

class Entity: AbstractAggregateRoot<Entity>() {
    ...
}

Now suppose that

  • repository.save(entity) is executed.
  • entity has an non-empty domain event list.
  • The transaction has been finished

Then jpa will dispatch and empty the event list inside of entity.

@Transactional
fun someFunction(){
    entity.update1(param1)
    repository.save(entity)

    entity.update2(param2)
    repository.save(entity)
    // after the end of this scope, transaction submitted,
    // events generated from update1 and update2 are then dispatched asynchronously
}

That is to say, the following listeners are not executed sequentially:

@Component
class Listener {
    @EventListener
    fun afterUpdate1on(event: Update1Finished) {}

    @EventListener
    fun afterUpdate2on(event: Update2Finished) {}
}

When Should we use Domain Events?

Atomic operations are no big deal

We simply create side effect within a domain (can be triggered from another domain) for which atomic operation is not of our concern. If we need atomic operation, we should keep the logic within a single transactional scope.

Decouple domain behaviour from entity

We simply decouple the Entity.behaviour() logic from our Entity because we know that the following happen:

Case 1.

  • The behaviour may involve multiple domain objects that are not within Entity
  • Those domain objects cannot be within the boundary of a single parent domain object (otherwise simply move our behaviour to the parent).

Case 2.

  • We simply want to trigger the behaviour by event because we have a listener to record all dispatched events as a historical record.

In both cases we just want the domain behaviour to simply register an event, which serves as an entrypoint for the subsequent domain actions in the DomainEventListener.

Remark 1: Domain Event as an Entrypoint

In both cases above please make sure our ApplicationService has only one domain event dispatched, which serves as an entrypoint and let DomainEventListner orchestrate the domain behaviours.

Remark 2: ApplicationService and DomainEventListener
  • Both ApplicationService and DomainEventListener orchestrate domain behaviours, but one serves for UI and one serves for events from domain object.

  • We can consider DomainEventListener as a substitute of ApplicationService to maintain the "behaviour pattern" (in order not to create inorganized "service" to avoid service explosion).

Remark 3: How about DomainService?

Continued from the above, domain services, as in ApplicationService layer, can be injected into DomainEventListner for code simplification and code reuse.

Note that we resort to domain service only when the logic cannot be fitted into one single entity.

For example, I have a set of Class's, and each class belongs to a Group. Now I want to create a set of classes and a single group at the same time, the mix of these actions can naturally be fitted into TimeTableDomainService.createRecurringClasses().

About Factory Method in Domain Objects

Define a Factory Method

This is a continuation of Remark 3: How about DomainService? above, it is worth a single short section discussing the creation of domain objects.

We should try implementing factory method inside a parent domain object as it makes the aggregate relation super clear (instead of sporadic "single method"'s spreaded among domain services).

For example:

class StudentPackage(...) {
    ...
    companion object {
        fun create(start_date: Long,
                  min: Int,
                  course_id: Int,
                  num_of_classes: Int,
                  default_classroom: Classroom,
                  startDay: DateTime
        ): StudentPackage {
            val pkg = StudentPackage(startDate = start_date.toDouble(),
                                    min = min,
                                    expiryDate = startDay.plusMonths(4).millis.toDouble(),
                                    courseId = course_id,
                                    numOfClasses = num_of_classes,
                                    defaultClassroom = default_classroom)
            // will be triggered once this enetity is saved
            pkg.registerCreatedEvent()
            return pkg
        }

        fun createRecurringClasses(
            startDay: DateTime,
            startHour: DateTime,
            numOfClasses: Int,
            min: Int,
            classroom: Classroom
        ): List<Class> {
            return (0 until numOfClasses).map { week ->
                val nextStartDay = startDay.plusWeeks(week)
                val nextStartHour = startHour.plusWeeks(week)
                Class(
                    dayUnixTimestamp = nextStartDay.millis.toDouble(),
                    hourUnixTimestamp = nextStartHour.millis.toDouble(),
                    min = min,
                    actualClassroom = classroom
                )
            }
        }
    }
}
Apply Factory Methods in ApplicationService

Here is how we apply these methods inside an ApplicationService (that serves the UI from a POST-URL):

1// StudentApplicationService
2@Transactional
3fun createStudentPackage(studentId: String, reqBody: CreatePackageRequest) {
4    val (
5        start_date,
6        start_time,
7        min,
8        num_of_classes,
9        course_id,
10        default_classroom,
11    ) = reqBody
12    val student = studentRepository.findByIdOrNull(UUID.fromString(studentId)) ?: throw Exception("Student not found")
13    val startDay = DateTime(start_date)
14    val startHour = DateTime(start_time)
15    val newPackage = StudentPackage.create(start_date = start_date,
16                                            min = min,
17                                            course_id = course_id,
18                                            num_of_classes = num_of_classes,
19                                            default_classroom = default_classroom,
20                                            startDay = startDay)
21    studentPackageRepository.save(newPackage)
22
23    val newClasses = StudentPackage.createRecurringClasses(
24        startDay = startDay,
25        startHour = startHour,
26        numOfClasses = num_of_classes,
27        min = min,
28        classroom = default_classroom
29    )
30
31    newPackage.addClasses(newClasses)
32    newPackage.updateOfficialEndDateFromLastClass()
33
34    val newGroup = ClassGroup()
35    newGroup.addClasses(newClasses)
36
37    student.addPackage(newPackage)
38    classGroupRepository.save(newGroup)
39    studentRepository.save(student)
40}

To make the function looks small we can abstract some detail into TimetableDomainService, but that seems meaningless to me at this point and thus no further simplification there.

About @Cascade(CascadeType.ALL), avoid saving the same Entity Twice

Let's continue the createStudentPackage example above.

Be careful if some fields have @Cascade(CascadeType.ALL) association like the Student-StudentPackage relation in our example, then we should not save the StudentPackage as it will be being saved when saving Student in line-37. Persisting an entity object twice in a transaction will lead to error.

About Invariances (不變量) in Aggregate Root

In DDD, an invariance is a business rule or condition that must remain true at all times within a consistency boundary (usually an aggregate)

Certainly each data persistence involves a validation rule. Domain object is reponsible for maintaining the invariance as it knows everything it needs.

Bad example

Let's build a timetable system for students in a school.

Rules.

  1. Each student registers a course by buying a package.
  2. Each package has many classes initially at a specific time.

That's all

Now we implement a function to let teachers change the time of a class of one student by drag-and-drop in the UI, then our domain object Class has the behaviour:

Problem. Two classes may collide: let be the time interval of class0 and that of class1, but then we must check !

Improved example, but not ideal
Implement Validation Rules

Ok, how about moving the method to an upper level, say to StudentPackage, so that we have knowledge to classes within a package?

Now we can implement our data validations inside a domain object (it is the most natural candidate to do this since it has almost all domain knowledge to do the validation).

The validations will be executed at the beginning of

StudentPackage.moveClass(classId, ...)

tentatively

But hangs on!!

No No No, a student can register multiple courses (therefore multiple packages), a single package is not enough!

Finally let's move the method to the next upper level --- The Student!

Final version

The returned group of classes will be saved in application service (which serves the UI)

Remark. Same validation rules can be reused when buying new packages.

Reference