0%
March 24, 2025

Code Separation of Domain Entity Class, Domain Behaviour (Actions) and the Corresponding Validations

DDD

kotlin

Function Literals with Receiver

We have discussed this topic before, reader who is unfamiliar with that concept can have a brief look at Function Literals with Receiver.

Code Separation

Original Bulky StudentPackage Class

This file is very huge, mixing the joined entities, the domain behaviours, the domain behaviour validations and also factory methods.

Code Separation Strategy

We separate the group of logic into the following classes:

  • StudentPackageAction
  • StudentPackageValidation
  • StudentPackageFactory

We use function literals with StudentPackage as a receiver to provide an additional scope in which methods in C are available, where C = Student{Action, Validation, Vactory}.

Example of resulting code style of calling domain behaviour
@Transactional
fun duplicateClasses(reqBody: DuplicateClassRequest) {
    val (classId, numberOfWeeks) = reqBody
    val existingClass = classRepository.findByIdOrNull(classId) ?: throw Exception("Class not found")
    val studentPackage = studentPackageRepository.findByIdOrNull(existingClass.studentPackage?.id!!)
        ?: throw TimetableException("Student package not found")

    studentPackage.action {
        addDuplicatedClasses(classId, numberOfWeeks)
        updateOfficialEndDateFromLastClass()
        resetClassNumbers()
    }

    studentPackageRepository.save(studentPackage)
}

In the code snippet above we have extracted

  • addDuplicatedClasses
  • updateOfficialEndDateFromLastClass
  • resetClassNumbers

into StudentPackageAction.

The lambda function accepted by studentPackage.action can access the methods in StudentPackageAction

Code Separation by Function Literals with StudentPackage as Receiver

Separated File Structure
StudentPackage Class (reduced into standard Entity class)
package dev.james.alicetimetable.commons.database.entities


import dev.james.alicetimetable.commons.database.enums.Classroom
import dev.james.processor.GenerateDTO
import jakarta.persistence.*
import jakarta.persistence.Table
import org.hibernate.annotations.*
import org.hibernate.annotations.CascadeType
import org.hibernate.dialect.PostgreSQLEnumJdbcType
import org.springframework.data.domain.AbstractAggregateRoot
import java.util.*


/**
 * This class is generated by jOOQ.
 */
@Suppress("UNCHECKED_CAST")
@Entity
@GenerateDTO
@DynamicInsert
@Table(
    name = "student_package",
    schema = "public"
)
class StudentPackage(
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int? = null,
    @Column(name = "start_date", nullable = false)
    var startDate: Double,
    @Column(name = "paid_at")
    var paidAt: Double? = null,
    @Column(name = "official_end_date")
    var officialEndDate: Double? = null,
    @Column(name = "expiry_date")
    var expiryDate: Double? = null,
    @Column(name = "min", nullable = false)
    var min: Int,
    @Column(name = "course_id", nullable = false)
    var courseId: Int,
    @Column(name = "created_at")
    @Generated
    var createdAt: Double? = null,
    @Column(name = "created_at_hk")
    @Generated
    var createdAtHk: String? = null,
    @Column(name = "num_of_classes")
    var numOfClasses: Int,
    @Column(name = "default_classroom")
    @Enumerated(EnumType.STRING)
    @JdbcType(PostgreSQLEnumJdbcType::class)
    var defaultClassroom: Classroom? = null,
    @Column(name = "uuid")
    @Generated
    var uuid: UUID? = null,
) : AbstractAggregateRoot<StudentPackage>() {
    @ManyToOne
    @JoinColumn(name = "course_id", updatable = false, insertable = false)
    var course: Course? = null

    @ManyToOne
    @JoinTable(
        name = "rel_student_studentpackage",
        joinColumns = [JoinColumn(name = "student_package_id", referencedColumnName = "id")],
        inverseJoinColumns = [JoinColumn(name = "student_id", referencedColumnName = "id")]
    )
    var student: Student? = null

    @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()


    fun <T> registerEvent_(event: T & Any): T {
        return registerEvent(event)
    }
}
StudentPackageAction Class
// StudentPackageAction.kt

fun <T> StudentPackage.action(block: StudentPackageAction.() -> T): T {
    val scope = StudentPackageAction(this)
    return scope.block()
}

class StudentPackageAction(private val pkg: StudentPackage) {
    fun updateOfficialEndDateFromLastClass() {
        pkg.officialEndDate = pkg.classes.lastOrNull()?.dayUnixTimestamp
    }

    fun markAsPaid() {
        pkg.paidAt = DateTime().millis.toDouble()
        pkg.registerEvent_(TimetableEvents.PackagePaidMarked(pkg.id!!))
    }

    fun calculateNewEndDate() {
        val lastClass = pkg.classes.sortedBy { it.hourUnixTimestamp }.lastOrNull()
        if (lastClass != null) {
            pkg.officialEndDate = DateTime(lastClass.hourUnixTimestamp.toLong()).plusMinutes(lastClass.min).millis.toDouble()
        }
    }

    fun markAsUnpaid() {
        pkg.paidAt = null
        pkg.registerEvent_(TimetableEvents.PackageUnpaidMarked(pkg.id!!))
    }

    fun resetClassNumbers() {
        pkg.registerEvent_(TimetableEvents.PackageResetClassNumbersRequested(pkg.id!!))
    }

    fun addNextPackage() {
        pkg.registerEvent_(TimetableEvents.AddNextPackageRequested(pkg.id!!))
    }


    fun addClasses(newClasses: List<Class>) {
        pkg.withValidation {
            validation.`should not create classes that are in the past`(newClasses)
            validation.`new classes should not have intersection with existing classes`(newClasses)
            validation.`new classes total mins should not exceed total minutes of remaining classes of a package`(newClasses)
        }
        addAndRemoveRedundantClasses(newClasses)
    }

    fun deleteOneClass(classId: Int) {
        removeSingleClass(classId)
        pkg.registerEvent_(TimetableEvents.SingleClassDeleted(classId))
    }


    fun addAndRemoveRedundantClasses(classes: List<Class>) {
        val allClasses = (pkg.classes.toMutableList() + classes).sortedBy { it.hourUnixTimestamp }
        val packageTotalMin = pkg.min*pkg.numOfClasses

        val finalizedClasses = mutableSetOf<Class>()
        var minCounter = 0

        allClasses.forEach {
            minCounter += it.min
            if (minCounter <= packageTotalMin) {
                finalizedClasses.add(it)
            } else {
                return@forEach
            }
        }

        pkg.classes = finalizedClasses
    }

    fun deleteGroupedClassesOfClass(classId: Int) {
        val cls = pkg.classes.find { it.id == classId }
        if (cls == null) {
            throw TimetableException("class cannot be found")
        }
        if (cls.group == null) {
            removeSingleClass(classId)
        } else {
            removeRemainingClass(classId, cls)
        }
        pkg.registerEvent_(TimetableEvents.GroupOfClassesDeleted(classId = classId))
    }


    fun removeRemainingClass(classId: Int, cls: Class) {
        val remainingClasses = pkg.classes.filter { it.hourUnixTimestamp >= cls.hourUnixTimestamp }
        pkg.classes.removeAll { it.id in remainingClasses.map { it.id } }
    }

    fun removeSingleClass(classId: Int) {
        pkg.classes.removeAll { it.id == classId }
    }

    fun addDuplicatedClasses(classId: Int, numberOfWeeks: Int) {
        pkg.withValidation {
            validation.`class must exist within package`(classId)
            validation.`the number of weeks to duplicate must be at least 2`(numberOfWeeks)
        }
        val cls = pkg.classes.find { it.id == classId } ?: throw TimetableException("no class can be found for $classId")
        val targetClassHrTimestamp = DateTime(cls.hourUnixTimestamp.toLong())
        val targetClassDayTimestamp = DateTime(cls.dayUnixTimestamp.toLong())
        val newClasses = (0 until numberOfWeeks).map { offset ->
            val classHrTimestamp = targetClassHrTimestamp.plusWeeks(1 + offset).millis.toDouble()
            val classDayTimestamp = targetClassDayTimestamp.plusWeeks(1 + offset).millis.toDouble()
            Class(dayUnixTimestamp = classDayTimestamp,
                  hourUnixTimestamp = classHrTimestamp,
                  min = cls.min,
                  actualClassroom = cls.actualClassroom)
        }
        this.addClasses(newClasses)
        pkg.registerEvent_(TimetableEvents.ClassDuplicatedEvent(classId = classId,
                                                                numOfWeeks = numberOfWeeks))
    }

    fun updateClassBasicInfo(classId: Int, basicInfo: UpdateClassRequest) {
        val targetClass = pkg.classes.find { it.id == classId } ?: throw TimetableException("no class found for $classId")
        val (_, min, class_status, remark, actual_classroom) = basicInfo
        targetClass.min = min
        targetClass.classStatus = class_status
        targetClass.remark = remark
        targetClass.actualClassroom = actual_classroom
        pkg.withValidation {
            validation.`target classes (possibly from other package) should not have time conflict with current package`(listOf(targetClass))
            validation.`modified minutes should not make the whole package exceed pre-defined total`()
        }
        pkg.registerEvent_(TimetableEvents.ClassInfoUpdated(reqBody = basicInfo))
    }


    fun update(reqBody: UpdatePackageRequest) {
        val (id1, num_of_classes, start_date, official_end_date, min1, course_id, default_classroom) = reqBody
        this.apply {
            pkg.numOfClasses = num_of_classes
            pkg.startDate = start_date.toDouble()
            pkg.officialEndDate = official_end_date.toDouble()
            pkg.min = min1
            pkg.courseId = course_id
            pkg.defaultClassroom = default_classroom
        }
        pkg.registerEvent_(TimetableEvents.PackageUpdated(reqBody))
    }

    fun registerCreatedEvent() {
        pkg.registerEvent_(TimetableEvents.PackageCreatedEvent(pkg.toDTO()))
    }
}
StduentPackageValidation Class
// StduentPackageValidation

package dev.james.alicetimetable.commons.database.validations.aggregate

import dev.james.alicetimetable.commons.database.entities.Class
import dev.james.alicetimetable.commons.database.entities.StudentPackage
import dev.james.alicetimetable.commons.exception.TimetableException
import dev.james.alicetimetable.commons.utilityclass.Interval
import org.joda.time.DateTime

fun <T> StudentPackage.withValidation(block: StudentPackageValidation.() -> T): T {
    val scope = StudentPackageValidation(this)
    return scope.block()
}

class StudentPackageValidation(private val pkg: StudentPackage) {
    val validation = Validation()

    inner class Validation {
        fun `should not create classes that are in the past`(newClasses: List<Class>) {
            val current = DateTime().millis.toDouble()
            newClasses.forEach {
                if (it.hourUnixTimestamp < current) {
                    throw TimetableException("Only classes in the future can be created.")
                }
            }
        }

        fun `modified minutes should not make the whole package exceed pre-defined total`() {
            val newTotal = pkg.classes.map { it.min }.sum()
            val predefinedTotal = pkg.min*pkg.numOfClasses
            if (newTotal > predefinedTotal) {
                throw TimetableException("modified minutes should not make the whole package exceed pre-defined total")
            }
        }

        fun `new classes total mins should not exceed total minutes of remaining classes of a package`(classes: List<Class>) {
            val currMillis = DateTime().millis.toDouble()
            val classesAttended = classes.filter { it.hourUnixTimestamp <= currMillis }
            val packageTotalMins = pkg.min*pkg.numOfClasses
            val consumedMins = classesAttended.map { it.min }.sum()
            val remainingMins = packageTotalMins - consumedMins
            val newClassesTotalMins = classes.map { it.min }.sum()
            if (newClassesTotalMins > remainingMins) {
                throw TimetableException("Class creation failed, requested (${newClassesTotalMins}mins) is more than available (${remainingMins}mins) in a package")
            }
        }

        fun `target classes (possibly from other package) should not have time conflict with current package`(classesToMove: Iterable<Class>) {
            val classIdsToMove = classesToMove.map { it.id }
            val classesOfConcern = pkg.classes.filter { it.id !in classIdsToMove }
            val hasIntersection = intersectionsOfTwoSetOfClasses(currClasses = classesOfConcern,
                                                                 newClasses = classesToMove)
            if (hasIntersection) {
                throw TimetableException("New classes have time conflict with existing classes")
            }
        }

        fun `new classes should not have intersection with existing classes`(newclasses: List<Class>) {
            val hasIntersection = intersectionsOfTwoSetOfClasses(currClasses = pkg.classes,
                                                                 newClasses = newclasses)
            if (hasIntersection) {
                throw TimetableException("New classes have time conflict with existing classes")
            }
        }


        fun `class must exist within package`(classId: Int) {
            val cls = pkg.classes.find { it.id == classId }
            if (cls == null) {
                throw TimetableException("Target class does not exist")
            }
        }

        fun `the number of weeks to duplicate must be at least 2`(numberOfWeeks: Int) {
            if (numberOfWeeks <= 1) {
                throw TimetableException("the number of weeks to duplicate must be at least 2")
            }
        }

        fun validateDuplicateClassRequest(cls: Class, numOfWeeks: Int) {
            val availableMins = pkg.min*pkg.numOfClasses
            val consumedMins = pkg.classes.map { it.min }.sum()
            val extraMinsRequested = cls.min*(numOfWeeks - 1)
            val remainingMins = availableMins - consumedMins
            val isValidRequest = remainingMins - extraMinsRequested >= 0

            if (!isValidRequest) {
                throw TimetableException("Remaining mins: $remainingMins, but $extraMinsRequested extra mins is requested")
            }

            if (numOfWeeks <= 1) {
                throw TimetableException("You should duplicate to at least 2 weeks")
            }

            if (cls.group != null) {
                throw TimetableException("Please detach the class from its parent group before duplicating it.")
            }
        }


        fun intersectionsOfTwoSetOfClasses(currClasses: Iterable<Class>, newClasses: Iterable<Class>): Boolean {
            val existingClassIntervals = currClasses.map { it.getInterval() }
            val newClassIntervals = newClasses.map { it.getInterval() }
            val intersectedIntervals = mutableSetOf<Interval>()

            for (oldInterval in existingClassIntervals) {
                for (newInterval in newClassIntervals) {
                    val interval = oldInterval.intersect(newInterval)
                    interval?.let {
                        intersectedIntervals.add(interval)
                    }
                }
            }

            val hasIntersection = intersectedIntervals.isNotEmpty()
            return hasIntersection
        }
    }

}
The factory methods

Unlike the previous examples, factory method usually has no instantiated instance to stick with (as they are static). We create a placeholder to work with extension function (an empty Factory class):

Empty Factory placeholder class
package dev.james.alicetimetable.commons.database.factories

class Factory

We will be using extension function to provide new scope to access factory methods (does it taste very Golang?).

Example of resulting code style
@Transactional
fun createClass(reqBody: CreateClassRequest) {
    val (numOfClasses, dayUnixTimestamp, hourUnixTimestamp, min, studentPackageId, actualClassroom) = reqBody
    val targetPackage = studentPackageRepository.findByIdOrNull(studentPackageId) ?: throw Exception("Student package not found")
    val factory = Factory()
    val classes = factory.studentPackage {
        createRecurringClasses(startDay = DateTime(dayUnixTimestamp.toLong()),
                                startHour = DateTime(hourUnixTimestamp.toLong()),
                                numOfClasses = numOfClasses,
                                min = min,
                                classroom = actualClassroom)
    }
    targetPackage.action {
        addClasses(classes)
        calculateNewEndDate()
        resetClassNumbers()
    }
    studentPackageRepository.save(targetPackage)
}
Factory implementation file
package dev.james.alicetimetable.commons.database.factories

import dev.james.alicetimetable.commons.database.actions.action
import dev.james.alicetimetable.commons.database.entities.Class
import dev.james.alicetimetable.commons.database.entities.StudentPackage
import dev.james.alicetimetable.commons.database.enums.Classroom
import org.joda.time.DateTime


fun <T> Factory.studentPackage(block: StudentPackageFactory.() -> T): T {
    val scope = StudentPackageFactory()
    return scope.block()
}

class StudentPackageFactory() {
    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.action {
            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
            )
        }
    }

}