Skip to content
BrunoRosendo edited this page Aug 9, 2023 · 22 revisions

Models play a fundamental role in managing and organizing data. They represent the application's core data entities, acting as a bridge between the application's database and the user interface (e.g. projects, accounts, etc.). They encapsulate data attributes and methods to interact with the data, ensuring a structured and consistent approach to data handling.

In Spring Boot, these models are automatically modeled to a pre-configured database. In our project, we use Spring Data JPA with a relational database (SQL), which is all we need to know when designing our model. We can then configure any well-known SQL database and Spring will handle the rest!

For development, an H2 database is used and persisted in a file specified in application.properties.

Contents

Entities

Entities play a vital role in web applications, representing the core objects that mirror real-world entities or concepts within the website's domain. In Spring, entities are implemented as simple classes (POJOs) annotated with @Entity. These classes typically define the entity's properties and incorporate annotations related to validation, database mapping, JSON serialization, and occasionally simple methods without business logic.

Take a look at this example of a typical entity:

@Entity
class Account(
    @field:Size(min = Constants.Name.minSize, max = Constants.Name.maxSize)
    var name: String,

    @Column(unique = true)
    @field:NotEmpty
    @field:Email
    var email: String,

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY, required = true)
    @field:Size(min = Constants.Password.minSize, max = Constants.Password.maxSize)
    var password: String,

    @Id @GeneratedValue
    val id: Long? = null
) {
    fun getFirstName(): String {
        return name.split(" ")[0]
    }
}

There are a few things to note here:

  • The @Id and @GeneratedValue annotations instruct Spring to use the id field as the database table's identifier and to generate its value automatically. The field is initialized to null by default to facilitate entity initialization, especially during testing.
  • For better maintainability, it is advisable to use constants, like the ones shown above, when adding validations to your model. This practice allows for easier adjustments of values when required in production.
  • Use var for fields that can be changed and val otherwise (be aware that they can still be changed if you create a new object with the same ID and persist it).

The significance of other present annotations will be explained in the following sections.

Source: Spring Data JPA Docs

Validation

Validating user-submitted data, such as forms, is crucial for any website. Different frameworks have their unique approaches to achieving this, and in the case of Spring, it employs validation annotations directly on the model's definition, providing a concise and clear way to define entities.

Spring Boot offers a variety of validators that can be referred to in this list (please verify if the list is up-to-date).

Let's consider one of the fields mentioned earlier:

@field:Size(min = Constants.Name.minSize, max = Constants.Name.maxSize)
var name: String

In this example, we use @field since the fields are declared directly in the constructor (using var). If we omit this specification, the validations will only be applied to the constructor parameters. This will lead to issues since validation is executed after the constructor phase! Note that annotations unrelated to validation (such as database or JSON management) do not need to use this modifier.

Creating a Custom Validator

Our capabilities would be significantly restricted if we were solely reliant on Spring's provided validators. The ability to design and implement our own custom validators tailored to the specific business logic and data within our application is of utmost importance. This process is straightforward, and illustrative instances can be found within the utils/validation package.

To craft a validator, we need two fundamental components: a class that extends ConstraintValidator, and an annotation class that references the validator. It can be depicted as follows:

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [NullOrNotBlankValidator::class])
@MustBeDocumented
annotation class NullOrNotBlank(
    val message: String = "{null_or_not_blank.error}",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<Payload>> = []
)

class NullOrNotBlankValidator : ConstraintValidator<NullOrNotBlank, String?> {
    override fun isValid(value: String?, context: ConstraintValidatorContext?): Boolean {
        return value == null || value.isNotBlank()
    }
}

This blueprint is consistent for every custom validator, so let's dissect it. Concerning the annotation class, several aspects warrant comprehension:

  • @Targetspecifies the elements of code to which the annotation can be applied. In this instance, it's exclusively usable for class fields.
  • @Retention dictates whether the annotation is stored in binary output and remains accessible for reflection. By default (runtime), both conditions are true, and you likely won't need to configure it.
  • @Constraint denotes that this annotation undergoes validation by Spring/JPA/Jakarta and adheres to their defined structure. It also associates the corresponding validator or validators (when used for multiple types).
  • @MustBeDocumented indicates that this annotation should appear in the target's documentation.
  • The class structure requires 3 fields:
    • message serves as the error message returned by the validator when the target isn't valid. If enclosed within {}, the error is retrieved from the configuration properties (further elaborated in Configuration).
    • groups allows you to define under which circumstances this validation should be triggered (read more here).
    • payload enables you to define a payload to be passed with the validation (rarely used).

Turning our attention to the validator, it's important to note the following:

  • There are two generics you need to define: the annotation class and the target type.
  • The pivotal isValid() method delineates the validation logic for the intended value. The initialize() method can also be overridden if deemed necessary.

Validating an entity

Mere specification of validations within the model doesn't suffice to ensure user-friendly error responses. To achieve this, we must design a custom validator and manually execute validation on entities whenever deemed necessary. Here's a snippet illustrating this process:

fun ensureValid(entity: T): T {
    val validator = LocalValidatorFactoryBean()
    val violations = validator.validate(entity)
    if (violations.isNotEmpty()) {
        throw ConstraintViolationException(violations)
    }
    return entity
}

It's important to note that this mechanism is already encapsulated within EntityDto and automatically triggered during entity creation or update operations. Thus, you likely won't need to manually implement this code. However, understanding this process is beneficial to anticipate potential scenarios. In our context, the validator is defined within ValidationConfig rather than being instantiated each time the ensureValid() function is invoked (for further insights, refer to Configuration and Understanding Spring).

Furthermore, since we're throwing an exception, it needs to be handled in an error controller. Read more about it in Error Handling.

Source: Reflectoring.

Relationships

Since our website follows a relational database schema, it is crucial to understand how it works in Spring. This facet of JPA can be hard to manage without a solid grasp, so careful reading is advised. Familiarity with SQL databases is recommended before diving into this section.

One-to-One Relationships

Among the various relationship types, the one-to-one relationship stands as the simplest. Defining it requires just two straightforward steps. To facilitate comprehension, let's examine an illustrative scenario involving a person and a dog:

@Entity
class Person(
    val name: String,

    @OneToOne
    @JoinColumn(name = "dog_id", referencedColumnName = "id")
    val dog: Dog,

    @Id @GeneratedValue
    val id: Long? = null
)

@Entity
class Dog(
    val name: String,

    @OneToOne(mappedBy = "dog")
    val owner: Person,

    @Id @GeneratedValue
    val id: Long? = null
)

The key takeaways from this are:

  • @JoinColumn(name = "dog_id", referencedColumnName = "id") crafts the foreign key for the relationship. In this case, both parameters could have been omitted as their default values coincide.
  • Person class assumes the role of the relationship's owner, thereby hosting the foreign key pertaining to the dog.
  • While the Dog class isn't the owner of the relationship, you can optionally employ @OneToOne with the mappedBy parameter (identifying the field in the owner class) to establish a bidirectional relationship. For those concerned about performance considerations, alternative methods for implementing bidirectional one-to-one relationships are available here.

This will generate a database schema corresponding to what's shown below:

website

One-to-Many Relationships

Defining a one-to-many relationship follows a similar structure as shown in the example above, but it's important to consider whether the relationship should be bi-directional, accessible from both involved entities.

Let's explore how to establish a one-way relationship where an entity requires a collection of other entities. Note that alternative data structures like Set or Map can also be employed for this purpose.

@Entity
class Person(
    val name: String,

    @OneToMany
    @JoinColumn
    val dogs: List<Dog>,

    @Id @GeneratedValue
    val id: Long? = null
)

@Entity
class Dog(
    val name: String,

    @Id @GeneratedValue
    val id: Long? = null
)

Once again, let's break this down:

  • Even though the Dog table will hold the foreign key to its owner, we are creating a one-way relationship and thus do not have access to the owner in the Dog class. For that reason, we can define a @JoinColumn on the Person class to achieve the same result.
  • The @JoinColumn annotation also offers optional parameters for defining the column name and the referenced ID's name.

For a bi-directional relationship, the approach would be slightly different:

@Entity
class Person(
    val name: String,

    @OneToMany(mappedBy = "owner")
    val dogs: List<Dog>,

    @Id @GeneratedValue
    val id: Long? = null
)

@Entity
class Dog(
    val name: String,

    @JoinColumn
    @ManyToOne
    val owner: Person,

    @Id @GeneratedValue
    val id: Long? = null
)

In this scenario, the Dog class is annotated with @ManyToOne and holds the @JoinColumn responsible for establishing the reference to its owner. On the Person side, the property that maps the relationship in the Dog class must be specified.

Both examples should generate a database schema similar to what's shown below:

website

Many-to-Many Relationships

Establishing a many-to-many relationship bears similarities to a one-to-many relationship, yet it introduces a few notable distinctions. Consider the preceding example, and imagine a scenario where multiple individuals could share ownership of a dog:

@Entity
class Person(
    val name: String,

    @ManyToMany
    @JoinTable(
        name = "owner_dog",
        joinColumns = [JoinColumn(name = "owner_id", referencedColumnName = "id")],
        inverseJoinColumns = [JoinColumn(name = "dog_id", referencedColumnName = "id")]
    )
    val dogs: List<Dog>,

    @Id @GeneratedValue
    val id: Long? = null
)

@Entity
class Dog(
    val name: String,

    @ManyToMany(mappedBy = "dogs")
    val owners: List<Person>,

    @Id @GeneratedValue
    val id: Long? = null
)

The significant differences are as follows:

  • Rather than establishing a join column, the @JoinTable annotation generates a table to accommodate the many-to-many relationship. It's noteworthy that all parameters are optional, and employing default values often suffices.
  • Person serves as the relationship's owner. If solely desiring a one-way relationship, the list of owners within the Dog class could theoretically be omitted; however, in such a case, a one-to-many relationship would be more fitting.
  • @ManyToMany must be specified on both sides, but the non-owner side (Dog in this context) must specify the property mapping the relationship within the owner class (Person).

This will generate a database schema corresponding to what's shown below:

website

Relationship Modifiers

There are a few very important modifiers we can add to the relationships that are fundamental to use, manage and display those entities. There are more than what's shown below but there are the most commonly used in our project.

Fetch Type

Whenever employing a relationship annotation such as @OneToOne, @OneToMany, or @ManyToMany, you have the option to specify a fetch parameter. By default, this parameter is set to LAZY, implying that the associated entity in the relationship won't be retrieved automatically when accessing the class containing the annotation. Consequently, these entities won't be serialized, and manual initialization is necessary if you intend to access them within your application (refer to Hibernate.initialize()).

In scenarios where the default behavior isn't suitable, you can adjust the fetch type to EAGER, leading the entities to be automatically included each time you retrieve the parent entity:

@JoinColumn
@OneToMany(fetch = FetchType.EAGER)
val dogs: List<Dog>,

Warning: Exercise caution when configuring the fetch type as EAGER on both ends of a bi-directional relationship, as this may result in infinite recursion!

Cascading Actions

Similar to SQL, cascading actions like updates or deletions can significantly simplify business logic in many scenarios.

Whenever employing a relationship annotation such as @OneToOne, @OneToMany, or @ManyToMany, you have the option to specify a cascade parameter with different cascading options. By default, no cascading options are included. There are a few options for this but the most important are:

  • PERSIST: Creates or updates the associated entities within the entity's table.
  • REMOVE: Deletes the entity from its table if it's not part of the relationship's data collection.
  • ALL: Applies all cascading types, tightly coupling the relationship (commonly used).

For instance, to create, update, or delete Dog entities whenever the dogs list within a Person is modified (and persisted in the database), you can incorporate the following code:

@JoinColumn
@OneToMany(cascade = [CascadeType.ALL])
val dogs: List<Dog>,

The @OnDelete annotation is also available, but it's often less valuable and can lead to confusion (as seen in this thread). Currently, we use this annotation only as a workaround to ensure that a join table is deleted when the non-owner side of a @ManyToMany relationship is deleted (see #88). However, this seems to be a test-specific issue, and the annotation doesn't serve a purpose in the live application.

Ordering by Column

To automatically order a relationship's data collection upon retrieval, you can use the @OrderByannotation within the model. This can be applied to both one-to-many and many-to-many relationships:

@JoinColumn
@OneToMany
@OrderBy("name")
val dogs: List<Dog>,

Furthermore, @OrderColumn preserves the insertion order of a relationship (i.e., the first inserted item remains the first). This can be useful if you wish to provide users control over collection sorting. However, exercise caution when using this, as it requires contiguous values and deleting entities can disrupt this feature.

Validating under Relationships

By default, when validating an entity, it will not trigger validation for entities contained within its relationships. This behavior might not be suitable for various scenarios, particularly when performing cascading operations. To enable recursive validation of entities, you can apply the @Valid annotation. This approach is applicable to all types of relationships:

@JoinColumn
@OneToMany
val dogs: List<@Valid Dog>,

This annotation can also be used outside of the model definition. For instance, it is very useful on the Controllers to validate user input.

Sources: Salitha Chathuranga, Baeldung, and Rogue Modron.

JSON Management

As outlined in the Architecture section, our API relies entirely on JSON for data interchange. This JSON data is seamlessly converted to and from the application's entities through the utilization of Data Transfer Objects (DTOs). It's noteworthy that the conversion of DTOs back into entities also leverages Jackson, thereby ensuring that all JSON configurations remain applicable. For this reason, configuring JSON conversions for both entities and their properties is of extreme importance.

A big number of JSON configurations are at our disposal, accessible either by configuring properties in the application.properties file or by directly applying annotations to the model/DTO definitions. Comprehensive information about these configurations can be found in the Configuration section and also detailed in the Baeldung article. In this segment, we will delve into the most crucial and commonly utilized annotations that facilitate the effective management of JSON serialization and deserialization.

@JsonProperty

  • Despite the name, the presence of this annotation is not mandatory for every property.
  • If specified, it serves to alter the property's name during both serialization (read) and deserialization (write) processes. This is achieved by specifying the desired name within the annotation, such as @JsonProperty("name").
  • Marks a property as required, by setting the optional parameter required to true. An exception is triggered if the property is absent from the incoming JSON or DTO (see Error Handling): @JsonProperty(required = true)
  • Defines the access visibility of the property, which is regulated through the optional access parameter. It can assume one of the following values:
    • JsonProperty.Access.WRITE_ONLY: The property is extracted from user input but remains hidden during serialization. This proves advantageous for sensitive information like passwords.
    • JsonProperty.Access.READ_ONLY: The property is displayed during serialization but is excluded from user input during deserialization.
    • JsonProperty.Access.READ_WRITE: The property is accessible for both reading and writing, independently of configuration specifics.
    • JsonProperty.Access.AUTO: This setting adopts the default configuration.

@JsonIgnore

The annotated property is completely ignored, both for writing and reading.

@JsonIgnoreProperties

Much like the @JsonIgnore annotation, @JsonIgnoreProperties operates at the class level and serves the purpose of specifying a collection of properties that should be ignored during serialization and deserialization processes.

@JsonFormat

  • Configures how the annotated property is serialized, typically utilized for dates or timestamps.
  • The pattern parameter plays a key role, dictating the desired format for serialization. For instance, you can use pattern = "dd-MM-yyyy HH:mm:ss") to return a timestamp.
  • The shape parameter defines the serialized JSON type (e.g. shape = Shape.STRING).
  • The timezone and locale parameters help define time zones and region formats.
  • For further insights into the functionality, refer to this article.

@JsonAlias

Defines one or more alternative names for a property during deserialization (input -> entity), for example:

@JsonAlias("publish_date", "createdAt")
val publishDate: Date,

@JsonUnwrapped

Defines properties that should be unwrapped/flattened when serialized or deserialized. This is particularly useful when you want to show the properties of a class at the root level of the resulting JSON structure.

data class GenerationUserDto(
    @JsonUnwrapped
    val account: Account,
    val roles: List<String>
)

@JsonManagedReference and @JsonBackReference

These annotations offer a solution for managing parent/child relationships directly during serialization and deserialization processes. When dealing with such relationships, you can utilize @JsonManagedReference in the parent class and @JsonBackReference in the child class.

For instance, let's consider their application in a simplified scenario involving the creation of generations and roles:

@Entity
class Generation(
    var schoolYear: String,

    @Id @GeneratedValue
    val id: Long? = null
) {
    @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, mappedBy = "generation")
    @JsonManagedReference
    val roles: MutableList<@Valid Role> = mutableListOf()
}

@Entity
class Role(
    var name: String,

    @Id @GeneratedValue
    val id: Long? = null
) {
    @JoinColumn
    @ManyToOne(fetch = FetchType.LAZY)
    @JsonBackReference
    lateinit var generation: Generation
}

It's important to note that these annotations cannot be directly used within constructors. You can refer to this issue for further information on this limitation. Additionally, the lateinit modifier is employed in the child class (Role) to avoid creating nullable properties.

Polymorphic Type Handling

By default, when dealing with polymorphic types (classes that extend a specified base class), the serialization process includes the property name and all fields of the subclass. While this behavior is suitable for most scenarios, there is the option to modify it and incorporate type information:

  • @JsonTypeInfo – This annotation specifies the type information to be included in serialization. Typically, it's used to include the type name (JsonTypeInfo.Id.NAME).
  • @JsonSubTypes – Used to indicate the subtypes of the annotated property type.
  • @JsonTypeName – This annotation assigns a type name to the annotated class.
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = As.PROPERTY, property = "type")
@JsonSubTypes(
    JsonSubTypes.Type(value = Dog::class, name = "dog"),
    JsonSubTypes.Type(value = Cat::class, name = "cat")
)
open class Animal(val name: String)

@JsonTypeName("dog")
class Dog(name: String, val barkVolume: Float = 0.0f) : Animal(name)

@JsonTypeName("cat")
class Cat(name: String, val lives: Int = 7) : Animal(name)

data class Zoo(val animal: Animal)

When serializing the Zoo class, the output could look like this:

{
    "animal": {
        "type": "dog",
        "name": "Bobby",
        "barkVolume": 0
    }
}

Similarly, you could perform deserialization using input similar to this:

{
    "animal": {
        "type": "cat",
        "name": "Meowi"
    }
}

Custom JSON Annotations

It's feasible to create custom JSON annotations by combining existing ones, particularly when you find yourself repeatedly using the same set of annotations together. To achieve this, you can define an annotation class that includes @JacksonAnnotationsInside:

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@JacksonAnnotationsInside
@JsonProperty(required = true)
@JsonFormat(pattern = "dd-MM-yyyy HH:mm:ss")
annotation class JsonRequiredTimestamp

Source: Baeldung

Advanced Modelling Techniques

Embeddables

For organizational purposes, there are instances where it's advantageous to assemble a particular collection of fields into a class, all without the need to produce extra database tables. Achieving this entails employing a pair of annotations: @Embeddable and @Embedded.

To illustrate, let's consider the scenario where various entities might use a date interval:

@Embeddable
class DateInterval(
    val startDate: Date,
    val endDate: Date? = null
)

@Entity
class Event(
    val title: String,

    @Embedded
    val dateInterval: DateInterval,

    @Id
    @GeneratedValue
    val id: Long? = null
)

This way, we have our model better organized and the generated database schema will still create the columns startDate and endDate in the Event table. In our project, the embeddable classes are in the model/embeddable/ package.

Source: Baeldung - Embeddables

Converters

We have the option to save a specific field in the database using a different format than what we use in the application. For instance, this could involve converting a custom class into a primitive. This approach lets us create unique data formats without adding complexity to the database. This feature is very important to the implementation of permissions in our project!

To achieve this, we can create a class that extends Spring JPA's AttributeConverter and make use of the @Converter and @Convert annotations. Let's take a simple example where we convert a person's full name into a single database column:

data class FullName(
    val name: String,
    val surname: String
)

@Entity
class Person(
    @field:Convert(converter = PermissionsConverter::class)
    val name: FullName,

    @Id
    @GeneratedValue
    val id: Long? = null
)

@Converter
class PermissionsConverter : AttributeConverter<FullName, String> {
    override fun convertToDatabaseColumn(attribute: FullName): String {
        return "${attribute.name} ${attribute.surname}"
    }
    override fun convertToEntityAttribute(dbData: String): FullName {
        val (name, surname) = dbData.split(" ")
        return FullName(name, surname)
    }
}

Source: Baeldung.

Entity Listeners

You have the ability to define actions that occur during an entity's lifecycle—before it gets created, updated, loaded, or deleted. Achieving this is made possible through annotations like @PrePersist, which can be applied directly to the model definition, or by creating a specific entity listener and using the @EntityListeners(<class of the listener>) annotation on the entity class itself. A comprehensive understanding of how these features are utilized can be found in this article.

In addition, certain Spring libraries provide listeners that are readily employable. Among these, the AuditingEntityListener stands out. This listener enables the utilization of features for auditing creation and updates, such as capturing the time and author of entity creation (@CreatedDate, @LastModifiedDate, @CreatedBy, and @LastModifiedBy). To illustrate, consider a Post entity that maintains data on its publish date and last update time:

@Entity
@EntityListeners(AuditingEntityListener::class)
class Post(
    val title: String,

    var body: String,

    @CreatedDate
    var publishDate: Date? = null,

    @LastModifiedDate
    @JsonFormat(pattern = "dd-MM-yyyy HH:mm:ss")
    var lastUpdatedAt: Date? = null,

    @Id @GeneratedValue
    val id: Long? = null
)

It's worth noting that the choice between displaying only the date, the timestamp, or both can be made using @JsonFormat or by changing the default configuration (the timestamp will always be stored in the database). For a more detailed exploration of this functionality, refer to this article.

Sources: Baeldung and Natthapon Pinyo.