-
Notifications
You must be signed in to change notification settings - Fork 0
Models
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
.
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 andval
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
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.
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:
-
@Target
specifies 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. Theinitialize()
method can also be overridden if deemed necessary.
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.
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.
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 themappedBy
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:
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 theDog
class. For that reason, we can define a@JoinColumn
on thePerson
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:
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 theDog
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:
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.
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!
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.
To automatically order a relationship's data collection upon retrieval, you can use the @OrderBy
annotation 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.
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.
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.
- 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.
-
The annotated property is completely ignored, both for writing and reading.
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.
- 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 usepattern = "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
andlocale
parameters help define time zones and region formats. - For further insights into the functionality, refer to this article.
Defines one or more alternative names for a property during deserialization (input -> entity), for example:
@JsonAlias("publish_date", "createdAt")
val publishDate: Date,
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>
)
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.
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"
}
}
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
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
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.
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.
Getting Started
Architecture Details
Implementation Details
Testing
Documentation
Deployment