0%
August 23, 2025

Value Objects and Embedded Classes

kotlin

spring

Value Objects with @Embedded and @Embeddable Annotations

Consider the following Student entity

1@Entity
2@DynamicInsert
3@GenerateDTO
4@Table(
5    name = "student",
6    schema = "public"
7)
8class Student(
9    @Id
10    @Column(name = "id")
11    @GeneratedValue(generator = "ulid_as_uuid")
12    var id: UUID? = null,
13    @Column(name = "first_name", nullable = false)
14    var firstName: String,
15    @Column(name = "last_name", nullable = false)
16    var lastName: String,
17    @Column(name = "chinese_first_name")
18    var chineseFirstName: String? = null,
19    @Column(name = "chinese_last_name")
20    var chineseLastName: String? = null,
21    @Column(name = "school_name", nullable = false)
22    var schoolName: String,
23    @Column(name = "student_code")
24    var studentCode: String? = null,
25    @Column(name = "grade", nullable = false)
26    var grade: String,
27    @Column(name = "phone_number")
28    var phoneNumber: String? = null,
29    @Column(name = "wechat_id")
30    var wechatId: String? = null,
31    //@Column(name = "birthdate", nullable = false)
32    //var birthdate: Double,
33    @Embedded
34    var birthdate: Birthdate,

Here we have simply changed the primitive type to a data class Birthdate, with the value object being defined by:

package dev.james.alicetimetable.domain.valueObject

import jakarta.persistence.Column
import jakarta.persistence.Embeddable

@Embeddable
data class Birthdate(
    @Column(name = "birthdate", nullable = false)
    val value: Double,
) {
    init {
        require(value > 0) { "Birthdate must be positive" }
    }
}

Now the validation logic will take place automatically. Moreover, when we save the entity JPA will extract the @Column-annotate fields from the @Embedded fields.

This is a very powerful technique in DDD and JPA, now we can validate Email, Address, PhoneNumber etc value objects within the class itself and the data-persistence from JPA will automatically save the annotated fields into the database.

35    @Column(name = "parent_email", nullable = false)
36    var parentEmail: String,
37    @Column(name = "created_at")
38    var createdAt: Double? = null,
39    @Column(name = "created_at_hk")
40    var createdAtHk: String? = null,
41    @Column(name = "parent_id")
42    var parentId: UUID? = null,
43    @Column(name = "gender", nullable = false)
44    @Enumerated(EnumType.STRING)
45    @JdbcType(PostgreSQLEnumJdbcType::class)
46    var gender: Gender,
47    @Column(name = "should_auto_renew_package")
48    var shouldAutoRenewPackage: Boolean? = null,
49    @Column(name = "is_active")
50    @Suppress("INAPPLICABLE_JVM_NAME")
51    @set:JvmName("setIsActive")
52    var isActive: Boolean? = null,
53    @Column(name = "preferred_name")
54    var preferredName: String? = null,
55    @Column(name = "remark")
56    var remark: String? = null,
57) : AbstractAggregateRoot<Student>() {
58    ...
59}

Interaction with toDTO() Method generated by kspKotlin

When we convert an entity class into a plain DTO object, there are two approches to instruct kspKotlin for data transform:

  1. Keep it as { ..., brithday: { value: ... }}
  2. Use the name in @Column(name="some_name") and convert it into a plain object with someName as one of the fields

We adopt the second approach as it makes no change to our codebase when we do the refactoring from a String-field to a Birthday-field.

The following article

has updated the GenerateDTOProcessor class to scan all the @Embedded-annotated fields, and then scan all @Column-annotated fields contained in it throughout the toDTO() code generation process.