Skip to content

Services and Controllers

BrunoRosendo edited this page Aug 30, 2023 · 15 revisions

The final two layers of our architecture, after Models and Repositories, are services and controllers (web layer). Respectively, they're the ones who hold the business logic of the application and the endpoints exposed by our API. In terms of dependencies, controllers mostly use services and services can depend on repositories or other services.

Contents

Services

Services encapsulate the entire business logic of the application, including interactions with external resources such as databases, files, and emails. They are implemented with Spring's services and resemble standard classes. They usually use repositories and other utility classes, such as FileUploader or PasswordEncoder.

Theoretically, it is possible to omit the services layer by directly implementing all its methods within the controllers. However, such an approach would strongly violate the principle of Separation of Concerns and would make it exceedingly challenging to avoid code duplication.

To create services, simply create a class with the @Service annotation. This annotation is just a specialization for @Component used for marking services. For instance:

@Service
class AccountService(
    private val repository: AccountRepository,
    private val encoder: PasswordEncoder,
    private val fileUploader: FileUploader
) {
    fun getAllAccounts(): List<Account> = repository.findAll().toList()

    fun getAccountById(id: Long): Account = repository.findByIdOrNull(id)
        ?: throw NoSuchElementException(ErrorMessages.accountNotFound(id))

    fun createAccount(dto: CreateAccountDto): Account {
        repository.findByEmail(dto.email)?.let {
            throw IllegalArgumentException(ErrorMessages.emailAlreadyExists)
        }

        val account = dto.create()
        account.password = encoder.encode(dto.password)

        dto.photoFile?.let {
            val fileName = fileUploader.buildFileName(it, dto.email)
            account.photo = fileUploader.uploadImage("profile", fileName, it.bytes)
        }

        return repository.save(account)
    }
}

To grasp the createAccount() method better, please read the section about DTO below. However, this is essentially what happens:

  • Verify if the email already exists and trigger an error if it does (for more details, see Error Handling).
  • Create the entity by calling dto.create()
  • Encode the password, upload the photo, and save the entity again in the repository.

IMPORTANT: Some services will need to use the @Transactional annotation to perform operations that handle relationships. This feature transforms all DB operations in a function (or all functions within a class) into a single transaction. This allows us to handle multiple entities at once in the same request. This is related to Hibernate's concept of sessions, which you can read more about here.

Source: StackOverflow - Transações

Controllers

The web layers define all routes/endpoints exposed in the API by using controllers. The function associated with each route usually only calls a few of the services' methods.

The web layer also assumes responsibility for handling exceptions that may occur in any layer of the program. It transforms these exceptions into informative error messages that are returned to the users. This is implemented in the ErrorController class. As the entry point of the application, this layer is crucial for authentication and serves as the initial line of defense against unauthorized users. For more information about this, refer to Error Handling.

To set up a controller, you'll need to create a class and apply the @RestController tag. This automatically includes the @Controller annotation, signaling that the class is a Spring component, and @ResponseBody, which tells the methods that their return values should be bound to the web response body.

Optionally, you can also use @RequestMapping to set a common route prefix for the whole class. For instance, an account controller might cover all endpoints that begin with <URL>/accounts. If you intend to employ validation tags within the controller, it's essential to mark the class with @Validated, or else the validations won't work.

Request Mapping

To create request access points, define a function within your controller and employ one of these annotations, each associated with standard HTTP methods:

  • @GetMapping
  • @PostMapping
  • @PutMapping
  • @DeleteMapping
  • @PatchMapping

All these annotations require at least one parameter to define the request path. This is added to the API's URL and to the path specified in the controller's @RequestMapping. Additional optional parameters include:

  • consumes: Changes the expected media type from JSON (default) to a different format, especially handy for multipart form data.
  • produces: Alters the media type returned from JSON (default) to another format.
  • params: Specifies request parameters, although other annotations are usually more intuitive for this purpose.
  • headers: Similar to params, but pertains to request headers.

Request Parameters

There are many useful annotations that can be applied to method arguments for defining the request parameters and similar variables:

  • @PathVariable: Retrieves a value defined directly in the URI (e.g. /accounts/{id}).
  • @RequestParam: Retrieves a value from the request's query parameters or form data, replacing the params parameter of mapping annotations.
  • @RequestHeader: Retrieves a value from the request's headers, replacing the headers parameter of mapping annotations.
  • @RequestPart: Retrieves a value from a segment of a multipart form data request.
  • @RequestBody: Retrieves the whole request body to a variable (triggers an error if the format is incorrect).
  • @CookieValue: Retrieves a value from the request's cookies.
  • @Valid: Validates the parameter by using the validation annotations found in its class definition (the controller must be annotated with @Validated).

Source: Spring Docs

URI Patterns

All of the mapping annotations (in classes or methods) can be enhanced using URI patterns. These are very useful to specify in detail which patterns we want to handle in each controller or method. Here are some examples, refer to the documentation for more:

  • /projects/{id}: Match a path segment and capture it as a variable.
  • /projects/{postSlug}**: Captures a variable with lower specificity, which is useful if another method is handling a more specific path (check docs for details).
  • /projects/**: Matches multiple path segments.
  • /resources/ima?e.png: Match one character in the path segment.
  • /resources/*.png: Matches zero or more characters in the path segment.
  • /projects/{postId:\d+}: Match a segment with regex and capture it as a variable (the syntax is {varName:regex}).

Finally, here's an example controller:

@RestController
@RequestMapping("/projects")
@Validated
class ProjectController(private val service: ProjectService) {

    @GetMapping
    fun getAllProjects() = service.getAllProjects()

    @GetMapping("/{id:\\d+}")
    fun getProjectById(@PathVariable id: Long) = service.getProjectById(id)

    @GetMapping("/{projectSlug}**")
    fun getProjectBySlug(@PathVariable projectSlug: String) = service.getProjectBySlug(projectSlug)

    @PostMapping("/new", consumes = ["multipart/form-data"])
    fun createProject(
        @RequestPart project: ProjectDto,
        @RequestParam
        @ValidImage
        image: MultipartFile
    ): Project {
        project.imageFile = image
        return service.createProject(project)
    }
}

Source: Spring Docs

Data Transfer Objects (DTOs)

A DTO is just a simple data object/class used to carry data between different layers of our application. They are essential for the following reasons:

  • They translate the user's input (JSON) into a usable Kotlin class without converting it directly to entities. That would be problematic because the entities usually contain fields we do not want the user to control (e.g. passwords and timestamps).
  • They translate the entities back to user responses (JSON). We violate this process by instead using the @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) annotation directly in the entity model when needed. This way, we simplify the process and still have a way of hiding unwanted fields from the users. There are still some cases when response DTOs (different formats, combination of entities, etc.) need to be created but this already eases the process.

DTOs create a small overhead when developing new functionalities but help maintain API consistency and avoid security concerns. Here's an example DTO used to map the user's input and create an Account entity:

class CreateAccountDto(
    val email: String,
    val password: String,
    val name: String,
    val bio: String?,
    val birthDate: Date?,
    @JsonIgnore
    var photoFile: MultipartFile?,
    val linkedin: String?,
    val github: String?,
    val websites: List<CustomWebsiteDto>?
) : EntityDto<Account>()

The CreateAccountDtoclass, as observed, extends another class named EntityDto. This extension is performed to simplify the utilization of DTOs that directly mirror entities, a common scenario for most DTOs. This approach helps circumvent the need for more cumbersome techniques such as manual mappers.

The EntityDto class is designed to work with a specific entity type. It provides methods that facilitate the automated creation or update of the designated entity, using the attributes present within the DTO. While there might exist libraries designed for similar tasks, the team chose to independently implement this logic. This decision is rooted in the fact that the task is not overly complex and adopting this approach ensures a streamlined solution and helps in avoiding unnecessary dependencies. Further insight into the implementation of this class is provided below.

Implementation of EntityDto

The typical workflow for utilizing the entity DTO involves the following steps:

  1. The entity class is inferred in the constructor.

Optionally, you can supply the entity class within the constructor of the DTO. However, this is often unnecessary due to the functionality of the getTypeConversionClassWithCache method. This method, accessed from the generic type, retrieves the entity class. To optimize performance and minimize reflection usage, the type is cached within the typeArgumentCache HashMap of the static DtoReflectionUtils object.

constructor(conversionClass: KClass<T>?) {
    this.entityClass = DtoReflectionUtils.getTypeConversionClassWithCache(this::class, conversionClass)
}
  1. Convert the value

The DTO undergoes conversion to a new entity through the utilization of the ObjectMapper class. This process employs the convertValue and updateValue methods, which respectively facilitate entity creation and update.

fun create(): T {
    val newEntity = objectMapper.convertValue(this, entityClass.java)
    return ensureValid(newEntity)
}

fun update(entity: T): T {
    val newEntity = objectMapper.updateValue(entity, this)
    return ensureValid(newEntity)
}
  1. Validate the entity

Validation of the entity is carried out using a pre-configured Validator. This validator is responsible for identifying validation errors within the DTO. If any violations are detected, a ConstraintViolationException is raised, providing a collection of violations. Conversely, if no issues are found, the entity is returned.

private fun ensureValid(entity: T): T {
    val violations = validator.validate(entity)

    if (violations.isNotEmpty()) {
        throw ConstraintViolationException(violations)
    }

    return entity
}

Source: Spring Docs and Youtube playlist that introduces Spring Boot w/ Kotlin in a very practical way