Share:

In this tutorial, we will build a fully functional CRM based on a typical microservice architecture. The languages and frameworks used are Java 17, Kotlin, Spring Boot 3, and React 18 for the front-end application. It is intended that you can follow through from start to finish without prior knowledge of microservices but it would be beneficial to have a basic understanding of Docker, React and Spring Boot development.

The entire suite of applications is dockerized and utilises the following microservice patterns:

  • API Gateway / Edge server
  • Centralised configuration
  • Distributed tracing
  • Service discovery

We have created numerous tutorials in the past that show how to build a simple CRM based on Monolithic architecture using Spring Boot, as well as Grails and Kotlin. If you are new to Microservices and haven’t yet seen those, it might benefit you to first see what we aim to create but within a familiar architecture.

As usual, I’ll utilize IntelliJ Idea as my preferred IDE. For the database layer, we’ll use an H2 in-memory database loaded with 1000 mock customer records. Spring Boot 3.1 for each of our services. And React 18 with Vite for the front-end client.

All code for this project can be found at our repository: https://github.com/tucanoo/microservices_CRM

Comparing our Monolith with Microservices

Monolith CRM architecture illustration

The monolithic architecture image showcases a single, Spring Boot (MVC) application. This diagram typically reflects a tightly coupled system where the user interface, business logic, and data access code are all part of one codebase and deployed as a single entity. Our previous tutorials on Spring Boot monoliths offer a glimpse into this world, where simplicity and straightforward development and deployment processes are paramount. This can be ideal for smaller applications or teams that don’t need to scale rapidly.

Microservices CRM architecture illustration

Contrastingly, microservices break down the monolithic approach into smaller, independently deployable services. Each service is self-contained and performs a specific business function. These services communicate over a network, which requires careful design to handle failure and ensure performance. The two images accompanying this tutorial encapsulate the distinction: the monolith as a singular unit versus a constellation of microservices. By adopting microservices, developers can enjoy increased modularity, making it easier to understand, develop, and test the application. Scalability is improved as each microservice can be scaled independently, and deployment is faster since only the modified services need to be redeployed. However, this architecture introduces complexity in terms of service interaction, data consistency, and distributed system coordination.

Tutorial Contents

Due to the number of sub-modules in the tutorial, below are the individual sections so you can jump straight to a specific part if required.

  1. Architecture overview
  2. Microservice development
    1. Shared functionality
    2. Customer Microservice
    3. User & Authentication Microservice
    4. Centralised Configuration with Spring Cloud Config
    5. Service Discovery with Eureka
    6. Edge Server with Spring Cloud Gateway
  3. Front end development
  4. Dockerizing the project
  5. Running the project
  6. Final thoughts

Architecture Overview

In this section, we’ll outline the overall architecture of our CRM application, highlighting the microservice design patterns we’re implementing. Our goal is to construct a system that’s not just a collection of services but a cohesive ecosystem working in harmony. We’re using a Gradle multi-module setup to compartmentalize our application logic, making it more manageable and modular. Let’s dive into the structure and function of each module.

CRM Features

Our CRM’s user interface will be intuitive and role-sensitive, providing login capabilities and a dashboard tailored to user permissions. Administrators will manage user accounts through a detailed CRUD (Create, Read, Update, Delete) interface. For customer management, we have designed a sophisticated grid system capable of sorting and filtering through a pre-populated dataset of 1,000 records. The system comes with three predefined user roles: standard, admin, and read-only, each with appropriate access levels.

API Module

The API is present to provide shared data across multiple modules. Here we define the Data Transfer Objects (DTOs) for our Customer and User Java Persistence API (JPA) entities, which are crucial for data transportation across service boundaries. Additionally, it contains interfaces for our controllers, ensuring a consistent contract for service communication. Enums and other shared utilities are also part of this module, aiming to reduce code duplication and foster a single point of reference for common components.

Customer Service

The Customer Service is dedicated to managing customer-related functionalities. It uses MapStruct mappers for efficient object mapping, facilitating seamless data transformation between DTOs and JPA entities. The REST controller layer listens for and handles incoming API requests, and the data layer manages customer entities, thus separating concerns for cleaner, more maintainable code.

User Service

Mirroring the Customer Service structure, the User Service manages user-related data and functions. It implements REST endpoints for user interactions and utilizes MapStruct for object mapping. A significant feature of this service is authentication, handled by the Spring Authorization Server, which secures our application by verifying user identities and granting tokens for authorized sessions. We could have created an individual service for Authentication, but since it ties in with our User service so closely, I thought it would be beneficial to show how we can have a service act as both a Resource server AND an Authorization server.

Spring Cloud Config Service

Centralized configuration is a hallmark of resilient microservices architectures. The Spring Cloud Config Service provides a centralized server for managing external properties for applications across all environments. This decoupling from the service codebase allows for dynamic configuration updates without the need for redeployment.

Service Discovery with Eureka

Microservice CRM with Netflix Eureka based Service Discovery screenshot

Service discovery enables our microservices to find and communicate with each other without hard-coded locations, offering flexibility in a dynamic environment. Eureka offers a service registry that allows services to discover and register themselves automatically, simplifying inter-service interaction.

Edge Server with Spring Cloud Gateway

Spring Cloud Gateway serves as the entry point for incoming requests, routing them to the appropriate backend services. It also acts as a Resource server, with authentication, working in tandem with the User Service to ensure secure access control.

Distributed Tracing

Microservices CRM showing Zipkin distributed tracing for customer update action

We integrate distributed tracing with Micrometer and Zipkin to monitor and troubleshoot the inter-service communication. This visibility is crucial for understanding system behaviour and quickly pinpointing issues.

Front End with Vite/React 18

Our user interface is crafted with React 18, leveraging Vite for an optimized developer experience. The front end interacts with the backend through the Edge Server, managing authentication and authorization flows to secure user interactions.

Microservice Development

The first step of this tutorial is to establish our directory structure. We will establish individual Gradle modules, each with its own Dockerfile. A Docker compose file will be established in the outer directory to package together and deploy all services.

Using IntelliJ, creating a new project selecting Kotlin, with the Gradle build system.

Kotlin CRM Project initialisation in Intellij.

Once created, you can delete the build.gradle.kts file and gradle.properties since we’re not going to build this outer project directly.

API Module

Create a new module, using Spring Initializer. Name it “api” and select Kotlin, Gradle and the Kotlin Gradle DSL option.

API module initialization

On the dependencies page, we will need to select Spring Reactive Web and Spring Data JPA.

API Module Dependencies

This module exists so we can reduce code duplication across the other services and provide a sensible location to define our backend API interfaces and any other code which might be shared across multiple modules.

Therefore we will define controller interfaces for both the Customer and User controllers as these represent our API. We can also include Data Transfer Objects(DTOs), and common Exceptions, along with a Global exception handler that all our API endpoints can utilise in case of errors.

An additional key piece of shared functionality will be the logic required to support the Grid components in the front-end app. This is so that it can interpret data from the parameters sent from the front end, such as filter settings, sort and pagination options and produce the necessary JPA Specifications which can in turn be used by our repositories.

Once the project is created, start by deleting the files we do NOT need which are created automatically. This would be the gradle folder, the HELP.md file and the two gradle wrapper files, gradlew and gradlew.bat. Going forward, you can remove these from all new modules.

Add the following files to the API module. You can find commented versions of each file in the accompanying source code repository.

Under the package com.tucanoo.api.enums, create an enum class to represent the different user roles in our CRM:
enum class Role {
    ADMIN,USER,READONLY_USER
}

Next under com.tucanoo.api.exceptions, create an InvalidInputException class that we will catch and respond to in case of receiving bad post data:

class InvalidInputException : RuntimeException {
    constructor()
    constructor(message: String?) : super(message)
    constructor(message: String?, cause: Throwable?) : super(message, cause)
    constructor(cause: Throwable?) : super(cause)
}

And a NotFoundException that we will return in the case of 404 responses:

public class NotFoundException : RuntimeException {
    constructor()
    constructor(message: String?) : super(message)
    constructor(message: String?, cause: Throwable?) : super(message, cause)
    constructor(cause: Throwable?) : super(cause)
}

Next under the same package, add a ServiceExceptionHandler class which, annotated with ControllerAdvice will automatically handle when these exceptions are fired and respond appropriately. Note this also will respond in the case of any unhandled Exception with a 500 / Internal server error.

@ControllerAdvice
class ServiceExceptionHandler {
    private val log = LoggerFactory.getLogger(this::class.java)
    @ExceptionHandler(NotFoundException::class)
    fun handleNotFoundException(e: NotFoundException): ResponseEntity<Any> {
        log.error("Resource not found", e)
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(mapOf("error" to e.message))
    }
    @ExceptionHandler(InvalidInputException::class)
    fun handleInvalidInputException(e: InvalidInputException): ResponseEntity<Any> {
        log.error("An InvalidInputException occurred: ", e)
        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
            .body(mapOf("error" to e.message))
    }
    @ExceptionHandler(Exception::class)
    fun handleGeneralException(e: Exception): ResponseEntity<Any> {
        log.error("An unhandled error occurred", e)
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(mapOf("error" to "An unexpected error occurred"))
    }
}

Shared Grid Functionality

Now we can define the model for our grid request handling, under the package com.tucanoo.api.grids we will need the following new classes.

/* Represents the decoded filter parameters passed from ReactDataGrid */
class GridFilter {
    var name: String? = null
    var operator: String? = null
    var type: String? = null
    var value: String? = null
    var active = true
}
/* Represents the sorting, pagination and filter parameters passed from ReactDataGrid */
class GridParams {
    var draw = 0
    var length = 0
    var start = 0
    var currentPage = 0
        get() = start / length     // Calculate the current page from the start and length
    var sort: String? = null
    var filter: String? = null
    // Apply some default pagination options
    fun DataGridParams() {
        draw = 1
        length = 30
        start = 30
        currentPage = 1
    }
}
// interface for mapping a grid entity to a row
fun interface GridEntityToRowMapper<T> {
    fun toCellData(entity: T): Map<String, Any?>
}
class CommonSpecification<T>(private val filters: List<GridFilter>) : Specification<T> {
    override fun toPredicate(root: Root<T>, query: CriteriaQuery<*>, criteriaBuilder: CriteriaBuilder): Predicate? {
        val predicates = mutableListOf<Predicate>()
        filters.forEach { filter ->
            if (filter.active != true)
                return@forEach
            when (filter.type) {
                "date" -> {
                    if (!StringUtils.hasText(filter.value))
                        return@forEach
                    val formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
                    val startTime = LocalDate.parse(filter.value, formatter).atStartOfDay()
                    val endTime = startTime.toLocalDate().plusDays(1).atStartOfDay()
                    predicates.add(criteriaBuilder.greaterThanOrEqualTo(root.get(filter.name), startTime))
                    predicates.add(criteriaBuilder.lessThan(root.get(filter.name), endTime))
                }
                "boolean" -> {
                    getBooleanPredicate(filter, root, criteriaBuilder)?.let { predicates.add(it) }
                }
                else -> {
                    getPredicate(filter, root, criteriaBuilder)?.let { predicates.add(it) }
                }
            }
        }
        return if (predicates.isNotEmpty()) criteriaBuilder.and(*predicates.toTypedArray()) else null

    }
    companion object {
        fun <T> getPredicate(filter: GridFilter, root: Root<T>, criteriaBuilder: CriteriaBuilder): Predicate? {
            if (!StringUtils.hasText(filter.value)) {
                return null
            }
            return when (filter.operator) {
                "contains" -> criteriaBuilder.like(
                    criteriaBuilder.lower(root.get(filter.name)),
                    "%${filter.value?.lowercase()}%"
                )
                "notContains" -> criteriaBuilder.notLike(
                    criteriaBuilder.lower(root.get(filter.name)),
                    "%${filter.value?.lowercase()}%"
                )
                "eq" -> criteriaBuilder.equal(root.get<String>(filter.name), filter.value)
                "neq" -> criteriaBuilder.notEqual(root.get<String>(filter.name), filter.value)
                "empty" -> criteriaBuilder.isNull(root.get<String>(filter.name))
                "notEmpty" -> criteriaBuilder.isNotNull(root.get<String>(filter.name))
                "startsWith" -> criteriaBuilder.like(
                    criteriaBuilder.lower(root.get(filter.name)),
                    "${filter.value?.lowercase()}%"
                )
                "endsWith" -> criteriaBuilder.like(
                    criteriaBuilder.lower(root.get(filter.name)),
                    "%${filter.value?.lowercase()}"
                )
                else -> null
            }
        }
        fun <T> getBooleanPredicate(filter: GridFilter, root: Root<T>, criteriaBuilder: CriteriaBuilder): Predicate? {
            // Convert the filter value to a boolean, defaulting to false if the conversion fails
            val booleanValue = filter.value?.toBooleanStrictOrNull() ?: return null
            val path = root.get<Boolean>(filter.name)
            return when (filter.operator) {
                "eq" -> if (booleanValue) criteriaBuilder.isTrue(path) else criteriaBuilder.or(criteriaBuilder.isFalse(path), criteriaBuilder.isNull(path))
                "neq" -> if (!booleanValue) criteriaBuilder.isTrue(path) else criteriaBuilder.and(criteriaBuilder.isFalse(path), criteriaBuilder.isNotNull(path))
                else -> null
            }
        }
    }
}

The CommonSpecification class efficiently constructs JPA Predicate objects for data filtering. It processes each filter from the frontend grid, checking for filter activation and type. For date filters, it parses and creates range predicates. The getBooleanPredicate and getPredicate methods handle boolean and other types, accommodating various operators like ‘contains’, ‘equals’, ‘starts with’, etc. This design enables dynamic and scalable filtering capabilities in the CRM application, catering to diverse querying needs.

@Component
class GridUtils(private val objectMapper: ObjectMapper) {
    private val log = LoggerFactory.getLogger(GridUtils::class.java)
    fun <T> getDataForDataGrid(
        params: GridParams,
        repository: JpaSpecificationExecutor<T>,
        mapper: GridEntityToRowMapper<T>
    ): String? {
        val filters: List<GridFilter> = getFiltersFromParams(params)
        val sort: Sort? = getSortFromParams(params)
        if (sort == null)
            return null
        val specification = CommonSpecification<T>(filters)
        val pageRequest: org.springframework.data.domain.Pageable =
            PageRequest.of(params.currentPage, params.length, sort)
        val entities: org.springframework.data.domain.Page<T> = repository.findAll(specification, pageRequest)
        val totalRecords: Long = entities.getTotalElements()
        val cells: MutableList<Map<String, Any?>> = ArrayList()
        entities.forEach(Consumer { entity: T -> cells.add(mapper.toCellData(entity)) })
        val jsonMap: MutableMap<String, Any> = HashMap()
        jsonMap["draw"] = params.draw
        jsonMap["recordsTotal"] = totalRecords
        jsonMap["recordsFiltered"] = totalRecords
        jsonMap["data"] = cells
        var json: String? = null
        try {
            json = objectMapper.writeValueAsString(jsonMap)
        } catch (e: JsonProcessingException) {
            e.printStackTrace()
        }
        return json
    }
    fun getSortFromParams(params: GridParams): Sort? {
        val sortInfo: Map<String, Any>? = try {
            val sortJSON = URLDecoder.decode(params.sort, StandardCharsets.UTF_8.name())
            objectMapper.readValue(sortJSON, object : TypeReference<Map<String, Any>>() {})
        } catch (e: Exception) {
            return null
        }
        if (sortInfo == null) return Sort.by(Sort.Direction.ASC, "id")
        val sortName = sortInfo["name"] as? String ?: "id"
        val sortDirection = if ((sortInfo["dir"]?.toString() ?: "1") == "1") {
            Sort.Direction.ASC
        } else {
            Sort.Direction.DESC
        }
        return Sort.by(sortDirection, sortName)
    }
    fun getFiltersFromParams(params: GridParams): List<GridFilter> {
        params.filter?.let {
            try {
                val filterJSON = URLDecoder.decode(it, "UTF-8")
                return objectMapper.readValue(filterJSON, object : TypeReference<List<GridFilter>>() {})
            } catch (e: Exception) {
                log.warn("Unable to parse filter JSON: $it", e)
            }
        }
        return emptyList()
    }
}

The ‘GridUtils' class forms an essential part of the data retrieval and processing mechanism for the CRM list pages. It primarily functions to fetch and format data according to the specifications received from the front-end grid. This class includes methods for handling sorting (getSortFromParams), filtering (getFiltersFromParams), and data retrieval (getDataForDataGrid).

  1. getDataForDataGrid: This method combines filtering, paging, and sorting to fetch data from the repository. It utilises CommonSpecification for filter criteria, constructs a PageRequest for pagination, and then retrieves the data. The fetched entities are then converted into a grid-compatible format using a mapper, and the result is serialized into JSON using the ObjectMapper.
  2. getSortFromParams: This method decodes and processes the sorting criteria from the grid parameters. It interprets the sorting direction and field, defaulting to ascending order by ‘id’ if no sorting is specified.
  3. getFiltersFromParams: This method handles the extraction and parsing of filter criteria from the grid parameters. It decodes the filter JSON and converts it into GridFilter objects, logging a warning if the parsing fails.

Customer-Centric Code

Next, we can define our Customer-centric shared code. You might wonder, ‘Shouldn’t customer-related code be confined to the customer microservice?’ This is a pertinent question. There are two primary reasons for our approach: First, we are defining an Interface in the shared API, not the implementation. This distinction is crucial as it enhances testability, allowing for more comprehensive testing outside the confines of individual services. Second, we’ll incorporate our Data Transfer Objects (DTOs) within the shared API. In practical scenarios, it’s common to interact with multiple DTOs from different services. Centralizing these DTOs in the shared API facilitates smoother integration and data management across services.

Under a package com.tucanoo.api.customer, create a CustomerDto class:

// Represents the customer data passed from the client
class CustomerDto {
    var id: Long? = null
    var firstName: String? = null
    var lastName: String? = null
    var emailAddress: String? = null
    var address: String? = null
    var city: String? = null
    var country: String? = null
    var phoneNumber: String? = null
}

Another class ICustomerController will represent our REST controller interface:

interface ICustomerController {
    /**
     * Retrieves a paginated list of customers based on the provided grid parameters.
     *
     * @param params The grid parameters to determine pagination and sorting.
     * @return A ResponseEntity containing a JSON string representation of the paginated customer data.
     */
    @GetMapping("/customer")
    fun get(params: GridParams): ResponseEntity<String>
    /**
     * Retrieves a specific customer by their ID.
     *
     * @param id The ID of the customer to retrieve.
     * @return A ResponseEntity containing the customer data, or a NOT_FOUND status if the customer does not exist.
     */
    @GetMapping("/customer/{id}")
    fun get(@PathVariable id: Long): ResponseEntity<CustomerDto>
    /**
     * Creates a new customer.
     *
     * @param body The customer data to create.
     * @return A ResponseEntity containing the created customer data.
     */
    @PostMapping(
        "/customer",
        consumes = ["application/json"],
        produces = ["application/json"]
    )
    fun createCustomer(@RequestBody body: CustomerDto): ResponseEntity<CustomerDto>
    /**
     * Updates an existing customer.
     *
     * @param body The customer data to update.
     * @return A ResponseEntity containing the updated customer data, or a NOT_FOUND status if the customer does not exist.
     */
    @PutMapping(
        "/customer",
        consumes = ["application/json"],
        produces = ["application/json"]
    )
    fun updateCustomer(@RequestBody body: CustomerDto): ResponseEntity<CustomerDto>
}

User-Centric code

Similarly, we can add the code for the User aspects. Under com.tucanoo.api.user, create a UserDto class:

// Represents the user data passed from the client
class UserDto {
    var id: Long? = null
    var role: Role? = null
    var username: String? = null
    var fullName: String? = null
    var password: String? = null
    var enabled: Boolean? = false
}

And a slightly different DTO for displaying a user with no password field:

class UserResponseDto {
    var id: Long? = null
    var role: Role? = null
    var username: String? = null
    var fullName: String? = null
    var enabled: Boolean? = false
}

And then the controller interface, IUserController:

interface IUserController {
    /**
     * Retrieves a paginated list of users based on the provided grid parameters.
     *
     * @param params The grid parameters to determine pagination and sorting.
     * @return A ResponseEntity containing a JSON string representation of the paginated user data.
     */
    @GetMapping("/user")
    fun get(params: GridParams): ResponseEntity<String>
    /**
     * Retrieves a specific user by their ID.
     *
     * @param id The ID of the user to retrieve.
     * @return A ResponseEntity containing the user data, or a NOT_FOUND status if the user does not exist.
     */
    @GetMapping("/user/{id}")
    fun get(@PathVariable id: Long): ResponseEntity<UserResponseDto>
    /**
     * Creates a new user.
     *
     * @param body The user data to create.
     * @return A ResponseEntity containing the created user data.
     */
    @PostMapping(
        "/user",
        consumes = ["application/json"],
        produces = ["application/json"]
    )
    fun createUser(@RequestBody body: UserDto): ResponseEntity<UserResponseDto>
    /**
     * Updates an existing user.
     *
     * @param body The user data to update.
     * @return A ResponseEntity containing the updated user data, or a NOT_FOUND status if the user does not exist.
     */
    @PutMapping(
        "/user",
        consumes = ["application/json"],
        produces = ["application/json"]
    )
    fun updateUser(@RequestBody body: UserDto): ResponseEntity<UserResponseDto>
}

That’s it for our Share API, you may continue reading or return to the contents.

Customer Microservice

We can now look at the Customer services for the isolated Customer-centric functions. This will essentially serve the front end by responding to API calls to list, create, update and retrieve single Customer records. It will also define the database entity.

Start by creating a new Spring Initializer module named customer-service, I like to keep the main microservices under their own subfolder, so be sure to specify a ‘microservices’ subfolder.

Customer microservice initialisation for CRM

In the next section for dependencies, select from web, devtools, cloud client, validation, jpa, h2 and zipkin. Hit create and open the build.gradle.kts and ensure to add the additional dependencies as follows:

dependencies {
    implementation(project(":api"))
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.cloud:spring-cloud-starter-config")
    implementation("org.springframework.cloud:spring-cloud-config-client:4.0.2") // address config bug
    implementation("org.springframework.retry:spring-retry")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    developmentOnly("org.springframework.boot:spring-boot-devtools")
    runtimeOnly("com.h2database:h2")
    implementation("org.mapstruct:mapstruct:${mapStructVersion}")
    kapt("org.mapstruct:mapstruct-processor:${mapStructVersion}")
    implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client")
    // Distributed tracing
    implementation ("io.micrometer:micrometer-tracing-bridge-otel")
    implementation ("io.opentelemetry:opentelemetry-exporter-zipkin")
    implementation ("net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.0")
}

Note: You will need to provide the mapstruct version, at the top of the build file add

val mapStructVersion = “1.5.5.Final”

And add the ‘kapt’ plugin to the plugins section which is required by Mapstruct:

kotlin(“kapt”) version “1.8.22”

If in doubt of any of the entries, please refer to the code in our repository.

You can also once again delete the unnecessary HELP.md, gradlew, and gradlew.bat files and the gradle folder.

We can start with the data components, under the package com.tucanoo.crm.core.customer.data create a new Customer class and define the entity as follows:

@Entity
class Customer {
    @Id
    @Column
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
    
    @NotBlank
    var firstName: String? = null
    @NotBlank
    var lastName: String? = null
    @Email
    var emailAddress: String? = null
    var address: String? = null
    var city: String? = null
    var country: String? = null
    var phoneNumber: String? = null
}

In the same package, create a repository named CustomerRepository and define it as follows:

@Repository
interface CustomerRepository : CrudRepository<Customer, Long>,
    PagingAndSortingRepository<Customer, Long>,
    JpaSpecificationExecutor<Customer>

Next, since we are using Mapstruct to convert between entities and data transfer objects we need to create a new class under com.tucanoo.cr.cor.customer.mappings named CustomerMapper and insert the following:

@Mapper(componentModel = "spring")
interface CustomerMapper {
    @Mappings(
        Mapping(target = "id", ignore = true),
    )
    fun toEntity(dto: CustomerDto): Customer
    fun updateEntity(dto: CustomerDto, @MappingTarget customer: Customer): Customer
    fun toDto(entity: Customer): CustomerDto
}

By utilising MapStruct, we are employing best practices in data mapping between DTOs and entities. MapStruct’s automatic conversion reduces boilerplate code, ensuring our codebase remains clean and maintainable. It offers a declarative approach to defining mapping rules, which enhances readability and reduces manual mapping errors. The integration with frameworks like Spring, as seen in our example, streamlines development, making our code more efficient and consistent. Note the updateEntity mapping which allows us to bind incoming data from our DTO onto an existing entity.

Next let’s add the first controller, implementing our previously declared interface ICustomerController. Under a new package com.tucanoo.crm.core.customer.controllers, create a new CustomerController class and add the code as follows:

@RestController
class CustomerController(
    private val mapper: CustomerMapper,
    private val customerRepository: CustomerRepository,
    private val gridUtils: GridUtils
) : ICustomerController {
    private val log = LoggerFactory.getLogger(CustomerController::class.java)
    /**
     * This mapper transforms a Customer entity into a map representation suitable for the data grid.
     * Each customer's information is mapped to a key-value pair where the key is the field name
     * and the value is the corresponding value for that customer.
     *
     * Additionally, the service's address is appended to each mapped customer.
     */
    private val customerDataGridMapper = GridEntityToRowMapper<Customer> { customer ->
        mapOf(
            "id" to customer.id,
            "firstName" to customer.firstName,
            "lastName" to customer.lastName,
            "emailAddress" to customer.emailAddress,
            "city" to customer.city,
            "country" to customer.country,
            "phoneNumber" to customer.phoneNumber
        )
    }
    override fun get(params: GridParams): ResponseEntity<String> {
        return ResponseEntity.ok(gridUtils.getDataForDataGrid(params, customerRepository, customerDataGridMapper))
    }
    override fun get(id: Long): ResponseEntity<CustomerDto> {
        log.debug("/customer return customer for id {}", id)
        val customer = customerRepository.findById(id).orElseThrow {
            log.warn("Customer not found for id {}", id)
            NotFoundException("Customer not found for id $id")
        }
        val response = mapper.toDto(customer)
        return ResponseEntity.ok(response)
    }
    override fun createCustomer(body: CustomerDto): ResponseEntity<CustomerDto> {
        log.debug("/createCustomer")
        val customer = mapper.toEntity(body)
        val newCustomer = customerRepository.save(customer)
        return ResponseEntity.ok(mapper.toDto(newCustomer))
    }
    @Transactional
    override fun updateCustomer(body: CustomerDto): ResponseEntity<CustomerDto> {
        log.debug("/updateCustomer")
        val customerId = body.id ?: throw InvalidInputException("Customer ID must be provided")
        val customer = customerRepository.findById(customerId)
            .orElseThrow { NotFoundException("Customer not found for id ${body.id}") }
        mapper.updateEntity(body, customer)
        val updatedCustomer = customerRepository.save(customer)
        return ResponseEntity.ok(mapper.toDto(updatedCustomer))
    }
}

This controller handles all our CRUD functionality, including filtering, sorting and pagination thanks to the GridUtils we previously defined in the shared API module.

That’s the main requirements of our Customer microservice catered for, but there are just a couple of additions to add.

To preload our fictitious dataset of 1000 customers, please download import.sql from our repository and save it to the resources folder. These will automatically be inserted into the database on application startup.

Secondly, there’s a special piece of config I have added to handle CORS issues during development when we’re running locally. Since our front end will be running on a different port to the service, we have to indicate to our system that it is ok to accept requests from our front-end app.

To do this, create a new class called SecurityConfig under the package com.tucanoo.crm.core.customer.config and add the following CORS configurator:

@Configuration
class SecurityConfig {
    // Allows CORS requests while running locally under default profile for testing/local development.
    @Bean
    @Profile("default")
    fun corsFilter(): CorsFilter {
        val source = UrlBasedCorsConfigurationSource()
        val config = CorsConfiguration()
        source.registerCorsConfiguration("/**", config)
        config.addAllowedOriginPattern("*")
        config.addAllowedHeader("*")
        config.addAllowedMethod("*")
        config.allowCredentials = true
        return CorsFilter(source)
    }
}

You might be wondering where is the rest of the configuration, there’s no application.yml or properties entries. Don’t worry, since we’re going to be using a centralised config service, we will visit this shortly.

That’s it for our Customer Microservice, you may continue reading or return to the contents.

User & Authentication Microservice

In case you wonder why we combine two services in one, this integrated approach streamlines our architecture by housing user data, credentials, and roles in one place, reducing the complexity of inter-service communication. Such a setup not only enhances performance through decreased latency but also ensures consistency in security policies and practices. Additionally, it simplifies both maintenance and scalability, as we manage and scale a single, focused service. While this method is effective for our tutorial’s application, the decision to integrate these functionalities should always be tailored to fit the specific objectives and requirements of any project.

This service closely follows in the footsteps of the Customer Microservice, however, we also introduced Spring Authorization Server to provide OAuth 2.1 based authentication. This version of Spring Boot (3) and Spring Authorization Server specifically targets OAuth 2.1, it’s worth noting one key difference between OAuth 2.0 and 2.1 is the drop of support for the Password grant type. What this means to us is that our front end can no longer capture and send a username and password directly to our authentication service. Instead, we will follow the recommended practice of allowing the user to click a login button, which directs them to our Authentication server, which itself captures a username and password and takes full responsibility for the entire authentication process. This also means we need to provide some views from our Authentication service to present to the user.

This might seem like a disadvantage at first, but when you consider it puts 100% of the authentication responsibilities in the hands of the Authentication service, which means no passwords are transferred across services, and opens up the door to enhance the service later without having to update the front end application. For example, we might want to add on 3rd party authentication or MFA functionality.

Let’s get started and create the new module:

user and authentication microservice initialization

There are a number of additional dependencies other than what the initializer allows you to choose, so for reference you can check the repository for the latest version, or see below for what I included at the time of writing this tutorial

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
val springBootVersion = "3.1.5"
val mapStructVersion = "1.5.5.Final"
val springCloudVersion = "2022.0.4"
plugins {
    id("org.springframework.boot") version "3.1.5"
    id("io.spring.dependency-management") version "1.1.3"
    kotlin("jvm") version "1.8.22"
    kotlin("plugin.spring") version "1.8.22"
    kotlin("plugin.jpa") version "1.8.22"
    kotlin("kapt") version "1.8.22"
}
group = "com.tucanoo"
version = "0.0.1-SNAPSHOT"
java {
    sourceCompatibility = JavaVersion.VERSION_17
}
repositories {
    mavenCentral()
}
dependencies {
    implementation(project(":api"))
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.cloud:spring-cloud-starter-config")
    implementation("org.springframework.cloud:spring-cloud-config-client:4.0.2") // address config bug
    implementation("org.springframework.retry:spring-retry")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    developmentOnly("org.springframework.boot:spring-boot-devtools")
    runtimeOnly("com.h2database:h2")
    implementation("org.mapstruct:mapstruct:${mapStructVersion}")
    kapt("org.mapstruct:mapstruct-processor:${mapStructVersion}")
    implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.security:spring-security-test")
    implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.security:spring-security-oauth2-authorization-server")
    // Distributed tracing
    implementation ("io.micrometer:micrometer-tracing-bridge-otel")
    implementation ("io.opentelemetry:opentelemetry-exporter-zipkin")
    implementation ("net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.0")
    implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
    implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
}
dependencyManagement {
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}")
    }
}
tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs += "-Xjsr305=strict"
        jvmTarget = "17"
    }
}
tasks.withType<Test> {
    useJUnitPlatform()
}
tasks.getByName<Jar>("jar") {
    enabled = false
}

Again, remove the unused files and folders, HELP.md, gradlew, gradlew.bat and the gradle folder.

Continue adding the data entity ( and repository code:

package com.tucanoo.crm.core.user.data
import com.tucanoo.crm.enums.Role
import jakarta.persistence.*
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import org.hibernate.annotations.CreationTimestamp
import java.time.LocalDateTime
@Entity
@Table(name = "appuser")
class User(
    @NotNull
    @Enumerated(EnumType.STRING)
    var role: Role? = null,
    @NotBlank
    @Column(nullable = false)
    var username: String? = null,
    @NotBlank
    @Column(nullable = false)
    var fullName: String? = null,
    @Column(nullable = false, length = 64)
    var password: String? = null,
    var enabled: Boolean = true
) {
    @Id
    @Column
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
    @Version
    @Column(nullable = true)
    var version: Int? = null
    @CreationTimestamp
    @Column(updatable = false)
    val dateCreated: LocalDateTime? = null
    fun toStr(): String {
        return "Customer(id=$id, fullName=$fullName, username=$username, password=$password, enabled=$enabled, role=$role, version=$version)"
    }
}

and for the repository:

package com.tucanoo.crm.core.user.data
import org.springframework.data.jpa.repository.JpaSpecificationExecutor
import org.springframework.data.repository.CrudRepository
import org.springframework.data.repository.PagingAndSortingRepository
import org.springframework.stereotype.Repository
import java.util.*
@Repository
interface UserRepository : CrudRepository<User, Long>,
    PagingAndSortingRepository<User, Long>,
    JpaSpecificationExecutor<User> {
    fun findByUsername(username: String): Optional<User>
}
Notice our additional 'finder' to lookup users by their username.
Next we can add a Mapstruct mapper to a mappings package:
@Mapper(componentModel = "spring")
interface UserMapper {
    @Mappings(
        Mapping(target = "id", ignore = true),
        Mapping(target = "version", ignore = true)
    )
    fun toEntity(dto: UserDto): User
    @Mappings(
        Mapping(target = "password", ignore = true)
    )
    fun updateEntity(dto: UserDto, @MappingTarget user: User): User
    
    fun toDto(entity: User): UserResponseDto
}

Now we’ll add our Controller implementation to provide the same CRUD functionality as we did with the ‘Customer’ service. Under a controllers package, create a new UserController class with the following:

@RestController
class UserController(
    private val serviceUtil: ServiceUtil,
    private val mapper: UserMapper,
    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder,
    private val gridUtils: GridUtils
) : IUserController {
    private val log = LoggerFactory.getLogger(UserController::class.java)
    /**
     * This mapper transforms a User entity into a map representation suitable for the data grid.
     * Each user's information is mapped to a key-value pair where the key is the field name
     * and the value is the corresponding value for that user.
     *
     * Additionally, the service's address is appended to each mapped user.
     */
    private val userDataGridMapper = GridEntityToRowMapper<User> { user ->
        mapOf(
            "id" to user.id,
            "username" to user.username,
            "fullName" to user.fullName,
            "enabled" to user.enabled,
            "dateCreated" to user.dateCreated,
            "serviceAddress" to serviceUtil.serviceAddress
        )
    }
    override fun get(params: GridParams): ResponseEntity<String> {
        return ResponseEntity.ok(gridUtils.getDataForDataGrid(params, userRepository, userDataGridMapper))
    }
    override fun get(id: Long): ResponseEntity<UserResponseDto> {
        log.debug("/user return user for id {}", id)
        val user = userRepository.findById(id).orElseThrow {
            log.warn("User not found for id {}", id)
            NotFoundException("User not found for id $id")
        }
        val response = mapper.toDto(user)
        return ResponseEntity.ok(response)
    }
    override fun createUser(body: UserDto): ResponseEntity<UserResponseDto> {
        log.debug("/createUser")
        val user = mapper.toEntity(body)
        user.password = passwordEncoder.encode(body.password)
        user.enabled = true
        val newUser = userRepository.save(user)
        return ResponseEntity.ok(mapper.toDto(newUser))
    }
    @Transactional
    override fun updateUser(body: UserDto): ResponseEntity<UserResponseDto> {
        log.debug("/updateUser")
        val userId = body.id ?: throw InvalidInputException("User ID must be provided")
        val user = userRepository.findById(userId)
            .orElseThrow({ NotFoundException("User not found for id ${body.id}") })
        mapper.updateEntity(body, user)
        val updatedUser = userRepository.save(user)
        return ResponseEntity.ok(mapper.toDto(updatedUser))
    }
}

It is practically identical to our Customer controller except we also make use of a Password Encoder when saving new Users.

Next, we can prepopulate the User database with some default User accounts, one to reflect each role. An administrator, a standard user and a user with only read-only privileges.

Under an ‘init’ package, create a new class named Bootstrap and insert the following:

@Component
class Bootstrap(
    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder
) {
    @EventListener
    fun appReady(event: ApplicationReadyEvent) {
        // Initialise initial users
        // Create admin user.
        if (!userRepository.findByUsername("admin").isPresent) {
            val adminUser = User(
                username = "admin",
                fullName = "Sheev Palpatine",
                password = passwordEncoder.encode("admin"),
                role = Role.ADMIN,
                enabled = true
            )
            userRepository.save(adminUser)
        }
        // Create standard user.
        if (!userRepository.findByUsername("user").isPresent) {
            val adminUser = User(
                username = "user",
                fullName = "Darth Tyranus",
                password = passwordEncoder.encode("user"),
                role = Role.USER,
                enabled = true
            )
            userRepository.save(adminUser)
        }
        // create readonly user
        if (!userRepository.findByUsername("readonly_user").isPresent) {
            val adminUser = User(
                username = "readonly_user",
                fullName = "Shin Hati",
                password = passwordEncoder.encode("readonly_user"),
                role = Role.READONLY_USER,
                enabled = true
            )
            userRepository.save(adminUser)
        }
    }
}

That’s essentially all we need for the CRUD aspects, next to begin configuring Spring Security and the Spring Authorization Server.

Under a security package, we can start adding classes to customise Spring Security and JWT token implementation. First some constants, so create a new class named SecurityConstants and insert the following constants to customise our token expiry settings:

object SecurityConstants {
    const val TOKEN_EXPIRATION_TIME_DAYS = 1L   // Day
    const val REFRESH_TOKEN_EXPIRATION_TIME_DAYS =  7L // 7 day
}

Next, create a CustomUserDetaills class which links our User entity with a Spring Security UserDetails implementation, insert the following:

class CustomUserDetails(private val user: User) : UserDetails {
    override fun getAuthorities(): Collection<GrantedAuthority?> {
        val authority = SimpleGrantedAuthority(user.role.toString())
        return List.of(authority)
    }
    override fun getPassword(): String {
        return user.password!!
    }
    override fun getUsername(): String {
        return user.username!!
    }
    override fun isAccountNonExpired(): Boolean {
        return true
    }
    override fun isAccountNonLocked(): Boolean {
        return true
    }
    override fun isCredentialsNonExpired(): Boolean {
        return true
    }
    override fun isEnabled(): Boolean {
        return user.enabled
    }
}

Under a services package, we’ll instruct Spring Security to use our repository to look up users during authentication. add the following class to utilise our custom user details implementation.

@Service
class CustomUserDetailsService(val userRepository: UserRepository) : UserDetailsService {
    override fun loadUserByUsername(username: String): UserDetails {
        val user = userRepository.findByUsername(username)
            .orElseThrow { UsernameNotFoundException("User not found") }!!
        return CustomUserDetails(user)
    }
}

Back to the security package, we need to introduce JWT configuration. Create a JwkConfiguration class and insert the following:

@Configuration
class JwkConfiguration {
    @Bean
    fun jwkSource(): JWKSource<SecurityContext> {
        val keyPair = generateRsaKey()
        val publicKey = keyPair.public as RSAPublicKey
        val privateKey = keyPair.private as RSAPrivateKey
        val rsaKey = RSAKey.Builder(publicKey)
            .privateKey(privateKey)
            .keyID(UUID.randomUUID().toString())
            .build()
        val jwkSet = JWKSet(rsaKey)
        return ImmutableJWKSet(jwkSet)
    }
    @Bean
    fun jwtDecoder(jwkSource: JWKSource<SecurityContext?>?): JwtDecoder {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource)
    }
    private fun generateRsaKey(): KeyPair {
        val keyPair: KeyPair
        keyPair = try {
            val keyPairGenerator = KeyPairGenerator.getInstance("RSA")
            keyPairGenerator.initialize(2048)
            keyPairGenerator.generateKeyPair()
        } catch (ex: Exception) {
            throw IllegalStateException(ex)
        }
        return keyPair
    }
}

This class is essential for our edge server, based on Spring Cloud Gateway functioning as a resource server. It generates and manages RSA key pairs, used for creating and validating JSON Web Tokens (JWTs). The public key from this configuration is later used by the gateway to verify the authenticity of tokens, ensuring secure communication between our user service (the authorization server) and the gateway (resource server).

Next, we’re going to introduce a JWT token customiser, so that we can include the users’ ‘Role’ in the token as a claim. This then allows our front-end app to securely know the users’ role so that it can determine which elements to render, or which to hide depending on the role.

Create a new class named JwtCustomiser and insert the following:

@Component
class JwtCustomiser {
    @Bean
    fun jwtCustomizer(): OAuth2TokenCustomizer<JwtEncodingContext> {
        return OAuth2TokenCustomizer<JwtEncodingContext> { context: JwtEncodingContext ->
            if (context.tokenType.value == OidcParameterNames.ID_TOKEN) {
                val principal =
                    context.getPrincipal<Authentication>()
                context.claims.claim("role", principal.authorities.first().authority)
            }
        }
    }
}

Lastly in our security package, we’re going to provide a custom authentication success handler. Spring Authorization Server users cookies as a session storage mechanism. Once you attempt to log in and provide working credentials, you naturally return back to the front-end app. But, what happens if you log out and log in again while you still have an open session? It logs you back in automatically without any additional request for credentials. This means you can’t sign in as a different user, and more importantly, anyone else who happens to click the login button can gain access without providing credentials.

So, my solution to this is to invalidate the authorization server session immediately upon successful authentication. You still get the JWT token back, but the ‘local’ authenticated session on the auth server will no longer be present so the next time you attempt to log in, it will, request the user enter credentials.

Create a class named CustomAuthHandler and enter the following:

class CustomAuthHandler : AuthenticationSuccessHandler {
    private val redirectStrategy = DefaultRedirectStrategy()
    override fun onAuthenticationSuccess(
        request: HttpServletRequest,
        response: HttpServletResponse,
        authentication: Authentication
    ) {
        if (authentication is OAuth2AuthorizationCodeRequestAuthenticationToken) {
            var result = authentication;
            var code = result.authorizationCode?.tokenValue
            var builder = UriComponentsBuilder
                .fromUriString(result.redirectUri.toString())
                .queryParam(OAuth2ParameterNames.CODE, code);
            if (StringUtils.hasText(result.state)) {
                builder.queryParam(OAuth2ParameterNames.STATE, result.state);
            }
            // invalidate the session, so any subsequent login goes through the login flow
            val oldSession : HttpSession?  = request.getSession(false);
            oldSession?.invalidate();
            redirectStrategy.sendRedirect(request, response, builder.toUriString());
        }
    }
}

Spring Authorization Server will provide us with a login screen by default, but for aesthetic reasons, we want it to be similar in appearance to our front-end application. Therefore we are going to want to override the view and provide our own. To do this, we also need to provide our own Login controller, even if it just contains a single endpoint to display the login page.

Under the ‘controllers’ package, create a LoginController class and enter the following:

@Controller
class LoginController {
    @GetMapping("/login")
    fun login(): String {
        return "login"
    }
}

Very simple indeed, and to provide the view, under the resources folder, under a templates subfolder, create a login.html file and enter the following HTML based on Thymeleaf for the dynamic elements and Bootstrap CSS to match our front-end app:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
    <title>Simple CRM - Customer management made Simple</title>
    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.8/css/all.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css">
</head>
<body>
<nav th:replace="~{/layout/navbar}"/>
<div class="container" style="margin-top:80px">
    <div class="mb-5">
        <h1>Welcome to Simple CRM</h1>
        <h2>Customer Management made Simple</h2>
    </div>
    <div class="row">
        <div class="col-12 col-md-4 offset-md-4">
            <div class="card">
                <article class="card-body">
                    <h4 class="card-title text-center mb-4 mt-1">Sign in</h4>
                    <hr>
                    <div class="alert alert-danger" th:if="${param.error}">
                        Invalid username or password.
                    </div>
                    <div class="alert alert-info" th:if="${param.logout}">
                        You have been logged out.
                    </div>
                    <form th:action="@{/login}" method="post">
                        <div class="input-group mb-4">
                            <span class="input-group-text"> <i class="fa fa-user"></i> </span>
                            <input name="username" class="form-control" placeholder="Username" autofocus required>
                        </div> <!-- input-group.// -->
                        <div class="input-group mb-4">
                            <span class="input-group-text"> <i class="fa fa-lock"></i> </span>
                            <input name="password" class="form-control" placeholder="******" type="password" required>
                        </div> <!-- input-group.// -->
                        <button type="submit" class="btn btn-primary w-100"> Login</button>
                    </form>
                </article>
            </div>
        </div>
    </div>
</div>
<footer th:replace="~{/layout/footer}"/>
</body>
</html>

Note, there are two nested templates, for the navigation bar and a footer. So under a layout subfolder, first add the nvabar.html:

<nav class="navbar navbar-dark bg-dark fixed-top justify-content-between px-4"
     xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
    <a class="navbar-brand" href="/">Simple CRM</a>
    <a class="mr-auto btn btn-sm btn-outline-light" href="/logout" sec:authorize="isAuthenticated()">Logout</a>
</nav>

And a footer.html:

<footer class="footer navbar-dark bg-dark fixed-bottom">
    <div class="container">
        <div class="row">
            <div class="col-md-4"></div>
            <div class="col-md-4">
                <p class="text-center text-muted mt-2">&copy;
                    <span th:text="${#dates.format(#dates.createNow(), 'yyyy')}"></span>
                    <a href="https://tucanoo.com" class="text-white">Tucanoo Solutions Ltd.</a>
                </p>
            </div>
        </div>
    </div>
</footer>
    <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"
        integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
        crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.min.js"></script>

Now all that’s remaining is some final configuration, to establish the Spring Security and Authentication Server rules.

Back in the src folders, under a new config package, create a SecurityConfig class and provide the following:

@Configuration
@EnableWebSecurity
class SecurityConfig {
    @Value("\${REDIRECT_URL:http://localhost}")
    lateinit var redirectUrl: String
    @Autowired
    lateinit var userRepository: UserRepository
    @Autowired
    lateinit var jwtDecoder: JwtDecoder
    @Autowired
    lateinit var customUserDetailsService: CustomUserDetailsService
    @Bean
    fun authenticationJwtTokenFilter(): AuthTokenFilter {
        return AuthTokenFilter(customUserDetailsService, jwtDecoder)
    }
    // security config for the authorization server endpoints
    @Bean
    @Order(1)
    @Throws(java.lang.Exception::class)
    fun authorizationServerSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)
        http
            .cors(Customizer.withDefaults())
            .csrf { obj: CsrfConfigurer<HttpSecurity> ->
                obj
                    .disable()
            }
            .exceptionHandling { exceptions: ExceptionHandlingConfigurer<HttpSecurity?> ->
                exceptions
                    .authenticationEntryPoint(LoginUrlAuthenticationEntryPoint("/login"))
            }
            .oauth2ResourceServer { resourceServer ->
                resourceServer
                    .jwt(Customizer.withDefaults())
            }
            .getConfigurer(OAuth2AuthorizationServerConfigurer::class.java)
            .authorizationEndpoint { authorizationEndpoint ->
                authorizationEndpoint
                    .authorizationResponseHandler(CustomAuthHandler()) // use our custom handler
            }
            .oidc(Customizer.withDefaults())
        return http.build()
    }
    // security config for the web endpoints (form based login)
    // note MVC pattern is explicitly used as we're using H2 database which causes an issue determining which type of request matcher to use
    @Bean
    @Order(2)
    @Throws(Exception::class)
    fun defaultSecurityFilterChain(http: HttpSecurity, mvc: MvcRequestMatcher.Builder): SecurityFilterChain {
        http
            .cors(Customizer.withDefaults())
            .csrf { obj: CsrfConfigurer<HttpSecurity> ->
                obj
                    .disable()
            }
            .formLogin { form: FormLoginConfigurer<HttpSecurity?> ->
                form
                    .loginPage("/login")
                    .permitAll()
            }
            .authorizeHttpRequests { requests ->
                requests
                    .requestMatchers(mvc.pattern("/login")).permitAll()
                    .requestMatchers(mvc.pattern("/actuator/**")).permitAll()
                    .requestMatchers(mvc.pattern("/error")).permitAll()
                    .requestMatchers(mvc.pattern("/oauth2/**")).permitAll()
                    .requestMatchers(mvc.pattern("/user/**")).permitAll()   // this filter chain should ignore the calls to the user endpoints
                    .anyRequest().authenticated()
            }
        return http.build()
    }

    @Bean
    fun mvc(introspector: HandlerMappingIntrospector?): MvcRequestMatcher.Builder? {
        return MvcRequestMatcher.Builder(introspector)
    }
    @Bean
    fun passwordEncoder(): PasswordEncoder {
        return BCryptPasswordEncoder()
    }
    @Bean
    fun registeredClientRepository(): RegisteredClientRepository {
        val oidcClient = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("frontend")
            .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .redirectUri(redirectUrl)
            .scope(OidcScopes.OPENID)
            .scope(OidcScopes.PROFILE)
            .clientSettings(
                ClientSettings.builder()
                    .requireAuthorizationConsent(false)
                    .requireProofKey(true)
                    .build()
            )
            .tokenSettings(
                TokenSettings.builder()
                    .accessTokenTimeToLive(Duration.ofDays(TOKEN_EXPIRATION_TIME_DAYS))
                    .refreshTokenTimeToLive(Duration.ofDays(REFRESH_TOKEN_EXPIRATION_TIME_DAYS))
                    .build()
            )
            .build()
        return InMemoryRegisteredClientRepository(oidcClient)
    }

    @Bean
    fun userDetailsService(): UserDetailsService? {
        return CustomUserDetailsService(userRepository)
    }
    @Bean
    fun authenticationProvider(): AuthenticationProvider? {
        val authenticationProvider = DaoAuthenticationProvider()
        authenticationProvider.setUserDetailsService(userDetailsService())
        authenticationProvider.setPasswordEncoder(passwordEncoder())
        return authenticationProvider
    }
    // Limit the CORS to running under default profile for testing locally.
    @Bean
    @Profile("default")
    fun corsFilter(): CorsFilter {
        val source = UrlBasedCorsConfigurationSource()
        val config = CorsConfiguration()
        config.allowCredentials = true
        config.addAllowedOriginPattern("*")
        config.addAllowedHeader("*")
        config.addAllowedMethod("*")
        source.registerCorsConfiguration("/**", config)
        return CorsFilter(source)
    }
    @Bean
    fun authorizationServerSettings(): AuthorizationServerSettings {
        return AuthorizationServerSettings.builder().issuer("http://user-service:7002").build()
    }
}

This class is responsible for a number of things, it defines the two filter chains for both the authorization login flow and also the JWT token authentication rules. We’re also setting up PKCE authentication which means we don’t have to keep a client ‘secret’ in our front-end or resource server applications. We also wire up our customer user details service, and once again add a CORS configuration bean to allow us to access the system locally from our front-end app when it’s running on a different port.

The last class required is a custom token filter, which will be used to verify the token using our custom user details service.

Create a new class, again in the config package called AuthTokenFilter and enter the following:

class AuthTokenFilter (private val customUserDetailsService: CustomUserDetailsService,
                       private val jwtDecoder: JwtDecoder
) : OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        try {
            val jwtString: String? = parseJwt(request)
            if (jwtString == null) {
                filterChain.doFilter(request, response)
                return
            }
            val jwt = jwtDecoder.decode(jwtString)
            if (jwt != null) {
                val username: String? = jwt.getClaimAsString(StandardClaimNames.PREFERRED_USERNAME)?.toString() ?: jwt.subject
                if (username != null) {
                    val userDetails: UserDetails = customUserDetailsService.loadUserByUsername(username)
                    val authentication = UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.authorities
                    )
                    authentication.details = WebAuthenticationDetailsSource().buildDetails(request)
                    SecurityContextHolder.getContext().authentication = authentication
                }
            }
        } catch (e: Exception) {
            logger.error("Cannot set user authentication: {}", e)
        }
        filterChain.doFilter(request, response)
    }
    private fun parseJwt(request: HttpServletRequest): String? {
        val headerAuth = request.getHeader("Authorization")
        return if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
            headerAuth.substring(7, headerAuth.length)
        } else null
    }
}

That’s it for the User and Authentication Microservice, you may continue reading or return to the contents.

Centralised Configuration

Adopting centralized configuration with Spring Config in this and any microservice project offers several key advantages. It ensures uniform and consistent configuration across all services, reducing the likelihood of errors and inconsistencies. This approach simplifies managing environment-specific settings, allowing for easy switches between development, testing, and production environments without altering service code or necessitating redeployments. Furthermore, Spring Config supports dynamic configuration updates, enabling your services to adapt to configuration changes in real time without a rebuild of the services. This not only enhances the system’s responsiveness but also streamlines administrative tasks.

There are various options available when using centralised as to the type of storage to use for the config data. The default offered by Spring Config is Git-based where you keep the config in a repository. Other options are the local file system, Vaults, Secret storage such as AWS secret manager and of course databases, whether JDBC or Redis.

In this tutorial, we’re going to simplify matters somewhat and opt for the file system. Our config files will consist of a general application.yml file and other ‘yml’ files named after each service.

Create a folder named config-files in the upper project direct and copy the files from the repository into the folder. There should be application.yml, customer-service.yml, gateway.yml, user-service.yml and service-discovery.yml.

Now we establish the Config server.

Create a new module based on Spring Initializer, specifying the same Kotlin settings as previously done in the topmost folder name centralised-config. When asked to choose the dependencies we only need Actuator and Config Server, as follows:

Spring Initializer Centralised Config Server dependencies

Once again, remove HELP.md, gradlew, gradlew.bat and the gradle folder.

Now we just need to configure the server where to find the config files. Create a resources/application.yml file and enter the following:

server.port: 8888
spring.cloud.config.server.native.searchLocations: file:./config-files
# WARNING: Exposing all management endpoints over http should only be used during development, must be locked down in production!
management.endpoint.health.show-details: "ALWAYS"
management.endpoints.web.exposure.include: "*"
logging:
  level:
    root: INFO
---
spring.config.activate.on-profile: docker
spring.cloud.config.server.native.searchLocations: file:/config-files

This sets up the port and the location where it can find the config files, for both our local development and docker environments.

We can now also configure our other microservices to connect to this server to retrieve their config.

Starting with the Customer Service, create a application.yml file under the resource folder and enter the following:

spring.config.import: "configserver:"
spring:
  application.name: customer-service
  cloud.config:
    failFast: true
    retry:
      initialInterval: 3000
      multiplier: 1.3
      maxInterval: 10000
      maxAttempts: 20
    uri: http://localhost:8888
---
spring.config.activate.on-profile: docker
spring.cloud.config.uri: http://centralised-config:8888

Likewise, for the User service, create an application.yml file under its resources folder and enter the following:

spring.config.import: "configserver:"
spring:
  application.name: user-service
  cloud.config:
    failFast: true
    retry:
      initialInterval: 3000
      multiplier: 1.3
      maxInterval: 10000
      maxAttempts: 20
    uri: http://localhost:8888
logging:
  level:
    root: INFO
---
spring.config.activate.on-profile: docker
spring.cloud.config.uri: http://centralised-config:8888

In this project, we only have 4 distinct services utilising centralised config and I think already you can see the advantages it brings. Imagine if we had 20, 50, 100 or more which is not unusual. Without the config for all servers being easily accessible under one location, management of the configuration would be incredibly difficult and error-prone.

Now we’ve established the configuration between our services, the next step is to introduce service discovery so that our services can find each other in a dynamic, resilient way.

Keep reading to see how we introduce Eureka Service Discovery or return to the contents.

Service Discovery with Eureka

Eureka, developed by Netflix, is a Service Discovery solution designed to overcome the limitations of using static IP addresses or DNS names in microservice architectures. Traditional methods struggle with cloud environments, where services frequently change and scale dynamically. This leads to problems with service availability and network reliability. Eureka addresses these issues by providing a dynamic service registry for automatic service registration and discovery. This system allows for real-time updates of service statuses, essential for effective load balancing and failover strategies. By using Eureka, our microservices become more resilient and adaptable, crucial for maintaining scalability and high availability in distributed systems.

To establish our discovery service, create a new module named service-discovery. Once again utilise Spring Initializer and look for Eureka service in the dependencies. Additionally, add Config client. We also need to manually add Spring Retry so your dependencies block should ultimately contain the following

    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-server")
    implementation("org.springframework.cloud:spring-cloud-starter-config")
    implementation("org.springframework.cloud:spring-cloud-config-client:4.0.2") // address config bug
    implementation("org.springframework.retry:spring-retry")
    testImplementation("org.springframework.boot:spring-boot-starter-test")

Once again remove the default new files we don’t need such as HELP.md, gradle, gradlew and the gradle folder.

We don’t need to add any code, but we do need to annotate our application’s main class to enable the Eureka server, so open ServiceDiscoveryApplication.kt and ensure you have the correct annotation as below:

@SpringBootApplication
@EnableEurekaServer
class ServiceDiscoveryApplication
fun main(args: Array<String>) {
    runApplication<ServiceDiscoveryApplication>(*args)
}

Then we just need to provide enough local configuration so this service can connect to our Config server, so under resources create an application.yml file and add the following:

spring.config.import: "configserver:"
spring:
  application.name: service-discovery
  cloud.config:
    failFast: true
    retry:
      initialInterval: 3000
      multiplier: 1.3
      maxInterval: 10000
      maxAttempts: 20
    uri: http://localhost:8888
logging:
  level:
    root: INFO
---
spring.config.activate.on-profile: docker
spring:
  cloud:
    config:
      profile: docker
      uri: http://centralised-config:8888

Next, we look at fronting all our services with an edge server, or you can return to contents.

Edge Server with Spring Cloud Gateway

In our tutorial, the next focus is the edge server, implemented using Spring Cloud Gateway. As the singular public-facing URL in our project architecture, the edge server acts as a critical entry point. It streamlines client interactions by routing requests to the appropriate internal services. This setup enhances security, as it’s the only exposed part of our system, effectively shielding other microservices, at least when we dockerize our application. Additionally, being the Spring Security resource server, it centralizes authentication and authorization. This approach simplifies security management, ensuring consistent policy enforcement across services. The edge server can also offer load balancing, improving system responsiveness and reliability. This results in cleaner, more focused microservices, and a more efficient overall system.

Create a new module, again with Spring Initializer named ‘gateway’. From the dependencies section you can choose a number of defaults such as Spring Security, Eureka client, and Config client but you will also need a few other dependencies as outlined below:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
    implementation("org.springframework.security:spring-security-oauth2-jose")
    implementation("org.springframework.cloud:spring-cloud-starter-gateway")
    implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client")
    implementation("org.springframework.cloud:spring-cloud-starter-config")
    implementation("org.springframework.cloud:spring-cloud-config-client:4.0.2") // address config bug
    implementation("org.springframework.retry:spring-retry")
    implementation ("io.micrometer:micrometer-tracing-bridge-otel")
    implementation ("io.opentelemetry:opentelemetry-exporter-zipkin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

Once the project is created, we need to establish the Spring Security filter chain so that we can dictate which requests require authentication and which don’t. Create a new ‘config’ package, and create a new class named SecurityConfig. Enter the following to define the gateway security and to enable load balancing:

@Configuration
@EnableWebFluxSecurity
class SecurityConfig {
    @Bean
    @Throws(Exception::class)
    fun defaultSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
        http
            .csrf { obj -> obj.disable() }
            .authorizeExchange { obj ->
                obj
                    .pathMatchers(HttpMethod.OPTIONS, "/**").permitAll() // Allow preflight requests
                    .pathMatchers("/actuator/**").permitAll()
                    .pathMatchers("/eureka/**").permitAll()
                    .pathMatchers("/oauth2/**").permitAll()
                    .pathMatchers("/login/**").permitAll()
                    .pathMatchers("/error/**").permitAll()
                    .pathMatchers("/webjars/**").permitAll()
                    .pathMatchers("/config/**").permitAll()
                    .anyExchange().authenticated()
            }
            .oauth2ResourceServer { obj -> obj.jwt(Customizer.withDefaults()) }
        val chain = http.build()
        return chain
    }
    @Bean
    @LoadBalanced
    fun loadBalancedWebClientBuilder(): WebClient.Builder {
        return WebClient.builder()
    }
}

Note: we are allowing any requests through to the Eureka service and Config service. In production, this would not be the case and we’d define security for these routes, but to simplify the architecture in this case we’re allowing unauthenticated requests through. We also establish the OAuth resource server and enforce JWT token authentication. It is the config from our Config service that includes the details as to ‘where’ our service can validate JWT tokens.

Next, we just need to include an application.yml under the resources folder to set up our gateway as a Config client and also include some routing details for when we’re running locally. Under Docker the routing details also come from the Config server.

Include the following into resources/application.yml:

spring.config.import: "optional:configserver:"
spring:
  application.name: gateway
  cloud.config:
    failFast: true
    retry:
      initialInterval: 3000
      multiplier: 1.3
      maxInterval: 10000
      maxAttempts: 20
    uri: http://localhost:8888

  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:7002
  cloud:
    config:
      enabled: false
    gateway:
      routes:
        - id: oauth2-server
          uri: http://localhost:7002
          predicates:
            - Path=/oauth2/**
        - id: oauth2-login
          uri: http://localhost:7002
          predicates:
            - Path=/login/**
        - id: oauth2-login-subpath
          uri: http://localhost:7002
          predicates:
            - Path=/login
        - id: oauth2-error
          uri: http://localhost:7002
          predicates:
            - Path=/error/**
        - id: customer
          uri: http://localhost:7001
          predicates:
            - Path=/customer/**
        - id: user
          uri: http://localhost:7002
          predicates:
            - Path=/user/**
logging:
  level:
    root: DEBUG
---
spring.config.activate.on-profile: docker
spring:
  cloud:
    config:
      enabled: true
      uri: http://centralised-config:8888

That’s it for our Gateway service, we can now move on to the front-end application, or you may return to contents.

Front end development

Now we are finished with the bulk of the back-end services, aside from the Docker setup, we can look at the front-end application.

We are going to build this app with React (18) and the Vite build system. IntelliJ should allow you to create the new module from within the IDE, however, if you face any issues, just use the command line and use npx / npm to create the vite app (” npm init vite frontend –template react “).

There are a number of dependencies we need for this project so from a terminal you want to install the following:

npm i yup react-select react-router react-router-dom react-redux react-oauth2-code-pkce react-hook-form react-bootstrap bootstrap bootstrap-icons axios @reduxjs/toolkit @inovua/reactdatagrid-community @hookform/resolvers

Some of these dependencies might be considered overkill for this project, but if we were to expand with additional pages, and more complex forms, then it’s better to adopt the best practices early rather than later for consistency throughout our components. Let’s quickly cover why we’re using these:

  • yup: Used for creating form schemas, providing a way to validate form values.
  • react-select: A flexible select component that offers customizable dropdowns for enhanced user interfaces.
  • react-hook-form, hookform/resolvers: A form management component that simplifies handling form state and validation.
  • bootstrap, bootstrap-icons, and react-bootstrap: For styling the application, providing a comprehensive suite of CSS and components for a consistent look and feel.
  • Axios: A popular HTTP client for making API requests, known for its ease of use and flexibility.
  • react-router, react-router-dom: Essential for handling navigation and routing between pages within the app.
  • react-redux, reduxjs/toolkit: Provides state management capabilities, enabling efficient data handling across different components.
  • react-oauth2-code-pkce: Streamlines the login process by handling the PKCE (Proof Key for Code Exchange) OAuth code flow, enhancing security.
  • inovua/reactdatagrid-community: Offers a feature-rich data grid component, ideal for displaying and interacting with large sets of data.

Front End Configuration

There are a number of configuration files for our front end, mostly to establish the Docker container exactly as we want it but for now, we can look at the critical details we need. Our front end needs to know where to make API requests, it needs to know the endpoints for our services, and how to authenticate users.

Create a .env file and insert the following:

VITE_LOGIN_URL=http://localhost:7002/oauth2/authorize
VITE_TOKEN_URL=http://localhost:7002/oauth2/token
VITE_USER_API_URL=http://localhost:7002
VITE_CUSTOMER_API_URL=http://localhost:7001
VITE_REDIRECT_URL=http://localhost

Now these are established defaults that we expected to be present in our environment, we can establish constants that the application can conveniently access. So create a folder under src name app, and create a new file called config.js and insert the following:

export const CUSTOMER_API_URL = import.meta.env.VITE_CUSTOMER_API_URL;
export const USER_API_URL = import.meta.env.VITE_USER_API_URL;
export const LOGIN_URL = import.meta.env.VITE_LOGIN_URL;
export const TOKEN_URL = import.meta.env.VITE_TOKEN_URL;
export const REDIRECT_URL = import.meta.env.VITE_REDIRECT_URL;
export const AUTH_TOKEN_NAME = "SIMPLE_CRM_TOKEN";

State management

We will use redux to maintain our authentication state, and we can use ‘redux toolkit’ to create a new ‘Slice’ to maintain this state.

Under app, create a slices folder and create a new file authSlice.js inserting the following:

import { createSlice } from '@reduxjs/toolkit';
const initialState = {
  isAuthenticated: false,
  token: null,
};
const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    loginSuccess: (state, action) => {
      state.isAuthenticated = true;
      state.token = action.payload;
    },
    logout: (state) => {
      state.isAuthenticated = false;
      state.token = null;
    },
  },
});
export const { loginSuccess, logout } = authSlice.actions;
export default authSlice.reducer;

Next, we need to define the redux store and link it to our slice, so under app create a store.js and insert the following:

import { configureStore } from '@reduxjs/toolkit';
import authReducer from './slices/authSlice';
export const store = configureStore({
  reducer: {
    auth: authReducer,
  },
});

API Calls

We are using axios for the HTTP transport functionality, and as such since we will be using token authentication, we want to ensure any requests we make contain the JWT token. Therefore we can create an enhanced instance of the base axios component that will automatically set the necessary request header. To achieve this, create a ‘network’ folder and a new file named API.js inserting the following:

import axios from "axios";
import {AUTH_TOKEN_NAME} from "../app/config.js";
const token = localStorage.getItem(AUTH_TOKEN_NAME + "_token")?.substring(1, localStorage.getItem(AUTH_TOKEN_NAME + "_token").length - 1);
// give 5 minute time out
const axiosInstance = axios.create({
  // baseURL: API_URL,
  timeout: 300000,
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json',
    'accept': 'application/json'
  }
});

export function setDefaultHeader(key, value) {
  axiosInstance.defaults.headers.common[key] = value;
}
// Request interceptor for API calls
axiosInstance.interceptors.request.use(
  async (config) => {
    const token = localStorage.getItem(AUTH_TOKEN_NAME + "_token").substring(1, localStorage.getItem(AUTH_TOKEN_NAME + "_token").length - 1);
    config.headers = {
      Authorization: `Bearer ${token}`,
    };
    return config;
  },
  (error) => {
    Promise.reject(error);
  }
);
axiosInstance.interceptors.response.use(function (response) {
    return response;
  }, function (error) {
    // Prevent infinite loops
    if (!error.response || error.response.status === 500) {
      console.error("RESPONSE NOT AVAILABLE. POSSIBLE SERVER CONNECTION ISSUE ", error)
      return Promise.reject(error);
    }
    // if we get a 401 (UNAUTHORIED) then return to sign in
    if (error.response?.status === 401) {
      window.location.href = '/logout';
      return Promise.reject(error);
    }
    // specific error handling done elsewhere
    return Promise.reject(error);
  }
);
export default axiosInstance;

This is a simplified version, however in production, it can be easily enhanced to also handle token expiry, so it would request a new token using a refresh token. All are completely transparent to the user.

Form Components

We want to make some reusable form components, so to avoid mass code duplication and improve our UI to the end user by showing the error state and linking our input components to the react hook form

Create a subfolder path/components/forms and create the following three new components (Note the suffix is JSX and not JS):

FormInput.jsx
import React from 'react';
import PropTypes from "prop-types";
const FormInput = ({register, fieldName, errors, params}) => {
  const inputClasses = ['form-control'];
  if (errors[fieldName]?.message) inputClasses.push('is-invalid');
  return <>
    <input className={inputClasses.join(' ')}
           {...register(fieldName)}
           {...params}
           aria-invalid={errors[fieldName]?.message ? "true" : "false"}
    />
    {errors[fieldName]?.message && <p role="alert">{errors[fieldName].message}</p>}
  </>
}
FormInput.propTypes = {
  fieldName: PropTypes.string.isRequired,  // name of the field
  errors: PropTypes.object,          // the errors object from react-hook-form
  register: PropTypes.func.isRequired,      // the register function from react-hook-form
  params: PropTypes.object,         // any additional parameters to pass to the input field
};
export default FormInput;
FormSelect.jsx
import React from 'react';
import PropTypes from "prop-types";
import Select from "react-select";
import {Controller} from "react-hook-form";
const FormSelect = ({control, fieldName, errors, params, options}) => {
  const inputClasses = ['form-control-select'];
  if (errors[fieldName]?.message) inputClasses.push('is-invalid');
  return <>
    <Controller
      name={fieldName}
      control={control}
      render={({field}) => <Select
        className={inputClasses.join(' ')}
        {...field}
        {...params}
        options={options}
        aria-invalid={errors[fieldName]?.message ? "true" : "false"}
        value={options.filter(c => field?.value === c.value)}
        onChange={(val) => {
          field.onChange(val.value)
        }}
      />}
    />
    {errors[fieldName]?.message && <p role="alert">{errors[fieldName].message}</p>}
  </>
}
FormSelect.propTypes = {
  fieldName: PropTypes.string.isRequired,  // name of the field
  errors: PropTypes.object,          // the errors object from react-hook-form
  control: PropTypes.object.isRequired,      // the control function from react-hook-form
  params: PropTypes.object,         // any additional parameters to pass to the input field
  options: PropTypes.array.isRequired,   // the options to display in the select
};
export default FormSelect;
FormSwitch.jsx
import React from 'react';
import PropTypes from "prop-types";
import {Form} from "react-bootstrap";
const FormSwitch = ({register, fieldName, errors, params}) => {
  const isInvalid = !!errors[fieldName]?.message;

  return <>
    <Form.Switch
                 id={fieldName}
                 label=""
                 {...register(fieldName)}
                 {...params}
                 isInvalid={isInvalid}
    />
    {isInvalid && <p role="alert">{errors[fieldName].message}</p>}
  </>
}
FormSwitch.propTypes = {
  fieldName: PropTypes.string.isRequired,  // name of the field
  errors: PropTypes.object,          // the errors object from react-hook-form
  register: PropTypes.func.isRequired,      // the register function from react-hook-form
  params: PropTypes.object,         // any additional parameters to pass to the input field
};
export default FormSwitch;

Customer components

Now we can begin to create the CRUD components for our app model.

Create a new folder under components named customer. Here we will create the grid component, a form, and a read-only view.

Create a CustomerGrid.jsx file and add the following:

import React, {useContext} from 'react';
import {AuthContext} from "react-oauth2-code-pkce";
import axiosInstance from "../../network/API.js";
import ReactDataGrid from "@inovua/reactdatagrid-community";
import '@inovua/reactdatagrid-community/index.css'
import {CUSTOMER_API_URL} from "../../app/config.js";
import {Link} from "react-router-dom";
const CustomerGrid = () => {
  const {idTokenData} = useContext(AuthContext);
  const canUserEdit = 'READONLY_USER' !== idTokenData?.role;
  const getData = async ({skip, limit, sortInfo, filterValue}) => {
    const sortData = sortInfo ?? null;
    const result = await axiosInstance.get(CUSTOMER_API_URL + "/customer", {
      params: {
        start: skip,
        length: limit,
        filter: encodeURIComponent(JSON.stringify(filterValue)),
        sort: encodeURIComponent(JSON.stringify(sortData))
      }
    })
    console.log("RESPONSE ", result)
    const data = result.data.data;
    const total = result.data.recordsTotal;
    return {data, count: parseInt(total)}
  }
  const linkPrefix = canUserEdit ? '/customer/edit/' : '/customer/';
  const columns = [
    {
      name: 'id',
      sortable: true,
      type: 'number',
      render: ({data}) => <Link to={linkPrefix + data.id}>{data.id}</Link>
    }, {
      name: 'firstName',
      header: 'First Name',
      sortable: true,
      render: ({data}) => <Link to={linkPrefix + data.id}>{data.firstName}</Link>
    }, {
      name: 'lastName',
      header: 'Last Name',
      sortable: true,
      render: ({data}) => <Link to={linkPrefix + data.id}>{data.lastName}</Link>
    }, {
      name: 'emailAddress',
      header: 'Email',
      flex: 1,
      sortable: true,
    }, {
      name: 'city',
      header: 'City',
      sortable: true,
    }, {
      name: 'country',
      header: 'Country',
      sortable: true,
    }, {
      name: 'phoneNumber',
      header: 'Phone',
      sortable: true,
    }
  ]
  const filterValue = [
    {name: 'id', operator: 'eq', type: 'number', value: ''},
    {name: 'firstName', operator: 'contains', type: 'string', value: ''},
    {name: 'lastName', operator: 'contains', type: 'string', value: ''},
    {name: 'emailAddress', operator: 'contains', type: 'string', value: ''},
    {name: 'city', operator: 'contains', type: 'string', value: ''},
    {name: 'country', operator: 'contains', type: 'string', value: ''},
    {name: 'phoneNumber', operator: 'contains', type: 'string', value: ''}
  ];
  return (
    <div>
      <ReactDataGrid
        idProperty="id"
        style={{minHeight: 550}}
        columns={columns}
        defaultSortInfo={{name: 'id', dir: 1}}
        dataSource={getData}
        defaultFilterValue={filterValue}
        pagination
        defaultLimit={10}
      />
    </div>
  );
};
export default CustomerGrid;

For the read-only view, create a CustomerDetail.jsx file and insert the following:

const CustomerDetail = ({data}) => {
  return (
    <article>
      <div className="row">
        <div className="form-group col-6 mb-3">
          <label className="form-label fw-bold">First Name</label>
          <input className="form-control-plaintext" value={data.firstName} readOnly={true}/>
        </div>
        <div className="form-group col-6 mb-3">
          <label className="form-label fw-bold">Last Name</label>
          <input className="form-control-plaintext" value={data.lastName} readOnly={true}/>
        </div>
      </div>
      <div className="row">
        <div className="form-group col-6 mb-3">
          <label className="form-label fw-bold">Email Address</label>
          <input className="form-control-plaintext" value={data.emailAddress} readOnly={true}/>
        </div>
        <div className="form-group col-6 mb-3">
          <label className="form-label fw-bold">Phone Number</label>
          <input className="form-control-plaintext" value={data.phoneNumber} readOnly={true}/>
        </div>
      </div>
      <div className="row">
        <div className="form-group col mb-3">
          <label className="form-label fw-bold">Address</label>
          <input className="form-control-plaintext" value={data.address} readOnly={true}/>
        </div>
      </div>
      <div className="row">
        <div className="form-group col-6 mb-3">
          <label className="form-label fw-bold">City</label>
          <input className="form-control-plaintext" value={data.city} readOnly={true}/>
        </div>
        <div className="form-group col-6 mb-3">
          <label className="form-label fw-bold">Country</label>
          <input className="form-control-plaintext" value={data.country} readOnly={true}/>
        </div>
      </div>
    </article>
  );
};
export default CustomerDetail;

For the edit form, create a CustomerForm.jsx which will be used for both creation and editing. Insert the following:

import React from 'react';
import * as yup from "yup";
import {useForm} from "react-hook-form";
import {yupResolver} from '@hookform/resolvers/yup';
import FormInput from "../forms/FormInput.jsx";
const CustomerForm = ({defaultValues, saveAction}) => {
  const formSchema = yup.object({
    firstName: yup.string().required(),
    lastName: yup.string().required(),
    emailAddress: yup.string().required().email(),
    phoneNumber: yup.string(),
    address: yup.string(),
    city: yup.string(),
    country: yup.string(),
  });
  const {register, formState: {errors}, handleSubmit} = useForm({
    mode: "onChange",
    defaultValues: defaultValues || {},
    resolver: yupResolver(formSchema)
  });
  return (
    <form onSubmit={handleSubmit(saveAction)} className="form">
      <div className="row">
        <div className="form-group col-6 mb-3">
          <label className="form-label fw-bold">First Name</label>
          <FormInput fieldName="firstName" register={register} errors={errors} params={{autoFocus: true}}/>
        </div>
        <div className="form-group col-6 mb-3">
          <label className="form-label fw-bold">Last Name</label>
          <FormInput fieldName="lastName" register={register} errors={errors}/>
        </div>
      </div>
      <div className="row">
        <div className="form-group col-6 mb-3">
          <label className="form-label fw-bold">Email Address</label>
          <FormInput fieldName="emailAddress" register={register} errors={errors}/>
        </div>
        <div className="form-group col-6 mb-3">
          <label className="form-label fw-bold">Phone Number</label>
          <FormInput fieldName="phoneNumber" register={register} errors={errors}/>
        </div>
      </div>
      <div className="row">
        <div className="form-group col mb-3">
          <label className="form-label fw-bold">Address</label>
          <FormInput fieldName="phoneNumber" register={register} errors={errors}/>
        </div>
      </div>
      <div className="row">
        <div className="form-group col-6 mb-3">
          <label className="form-label fw-bold">City</label>
          <FormInput fieldName="city" register={register} errors={errors}/>
        </div>
        <div className="form-group col-6 mb-3">
          <label className="form-label fw-bold">Country</label>
          <FormInput fieldName="country" register={register} errors={errors}/>
        </div>
      </div>
      <div className="row mt-5">
        <div className="col">
          <button type="submit" className="btn btn-success w-100">
            {defaultValues?.id ? "Save Changes" : "Create Customer"}
          </button>
        </div>
      </div>
    </form>
  );
};
export default CustomerForm;

User components

Next, create similar forms for User management. Although we don’t need a read-only view for users, since only administrators will have access to the user management section. We will have different forms for editing and creating users. Add the following new components to a components/user folder:

CreateUserForm.jsx
import React from 'react';
import * as yup from "yup";
import {useForm} from "react-hook-form";
import {yupResolver} from '@hookform/resolvers/yup';
import FormInput from "../forms/FormInput.jsx";
import FormSelect from "../forms/FormSelect.jsx";
const CreateUserForm = ({defaultValues, saveAction}) => {
  const formSchema = yup.object({
    fullName: yup.string().required(),
    role: yup.string().required(),
    username: yup.string().required(),
    password: yup.string().required(),
  });
  const {register,control, formState: {errors}, handleSubmit} = useForm({
    mode: "onChange",
    defaultValues: defaultValues || {},
    resolver: yupResolver(formSchema)
  });
  return (
    <form onSubmit={handleSubmit(saveAction)} className="form">
      <div className="row">
        <div className="form-group col-12 col-sm-6  mb-3">
          <label className="form-label fw-bold">Full Name</label>
          <FormInput fieldName="fullName" register={register} errors={errors} params={{autoFocus: true}}/>
        </div>
        <div className="form-group col-12 col-sm-6  mb-3">
          <label className="form-label fw-bold">Role</label>
          <FormSelect
            fieldName="role"
            options={[{value: "ADMIN", label:"Administrator"}, {value: "USER", label:"Standard User"}, {value: "READONLY_USER", label:"Read-only User"}]}
            control={control} errors={errors}/>
        </div>
      </div>
      <div className="row">
        <div className="form-group col-12 col-sm-6 mb-3">
          <label className="form-label fw-bold">Username</label>
          <FormInput fieldName="username" register={register} errors={errors}/>
        </div>
        <div className="form-group col-12 col-sm-6 mb-3">
          <label className="form-label fw-bold">Password</label>
          <FormInput fieldName="password" register={register} errors={errors} params={{type: "password"}}/>
        </div>
      </div>

      <div className="row mt-5">
        <div className="col">
          <button type="submit" className="btn btn-success w-100">
            {defaultValues?.id ? "Save Changes" : "Create User"}
          </button>
        </div>
      </div>
    </form>
  );
};
export default CreateUserForm;
EditUserForm.jsx
import React from 'react';
import * as yup from "yup";
import {useForm} from "react-hook-form";
import {yupResolver} from '@hookform/resolvers/yup';
import FormInput from "../forms/FormInput.jsx";
import FormSelect from "../forms/FormSelect.jsx";
import FormSwitch from "../forms/FormSwitch.jsx";
const EditUserForm = ({defaultValues, saveAction}) => {
  const formSchema = yup.object({
    fullName: yup.string().required(),
    role: yup.string().required(),
    username: yup.string().required(),
    enabled: yup.bool(),
  });
  const {register,control, formState: {errors}, handleSubmit} = useForm({
    mode: "onChange",
    defaultValues: defaultValues || {},
    resolver: yupResolver(formSchema)
  });
  return (
    <form onSubmit={handleSubmit(saveAction)} className="form">
      <div className="row">
        <div className="form-group col-12 col-sm-6  mb-3">
          <label className="form-label fw-bold">Full Name</label>
          <FormInput fieldName="fullName" register={register} errors={errors} params={{autoFocus: true}}/>
        </div>
        <div className="form-group col-12 col-sm-6  mb-3">
          <label className="form-label fw-bold">Role</label>
          <FormSelect
            fieldName="role"
            options={[{value: "ADMIN", label:"Administrator"}, {value: "USER", label:"Standard User"}, {value: "READONLY_USER", label:"Read-only User"}]}
            control={control} errors={errors}/>
        </div>
      </div>
      <div className="row">
        <div className="form-group col-12 col-sm-6 mb-3">
          <label className="form-label fw-bold">Username</label>
          <FormInput fieldName="username" register={register} errors={errors}/>
        </div>
        <div className="form-group col-12 col-sm-6 mb-3">
          <label className="form-label fw-bold">Enabled</label>
          <FormSwitch fieldName="enabled" register={register} errors={errors} />
        </div>
      </div>

      <div className="row mt-5">
        <div className="col">
          <button type="submit" className="btn btn-success w-100">
            {defaultValues?.id ? "Save Changes" : "Create User"}
          </button>
        </div>
      </div>
    </form>
  );
};
export default EditUserForm;
UserGrid.jsx
import axiosInstance from "../../network/API.js";
import ReactDataGrid from "@inovua/reactdatagrid-community";
import SelectFilter from "@inovua/reactdatagrid-community/SelectFilter";
import '@inovua/reactdatagrid-community/index.css'
import {USER_API_URL} from "../../app/config.js";
import {Link} from "react-router-dom";
const UserGrid = () => {
  const getData = async ({skip, limit, sortInfo, filterValue}) => {
    const sortData = sortInfo ?? null;
    const result = await axiosInstance.get(USER_API_URL + "/user", {
      params: {
        start: skip,
        length: limit,
        filter: encodeURIComponent(JSON.stringify(filterValue)),
        sort: encodeURIComponent(JSON.stringify(sortData))
      }
    })
    console.log("RESPONSE ", result)
    const data = result.data.data;
    const total = result.data.recordsTotal;
    return {data, count: parseInt(total)}
  }
  const statusFilterSources =
    [
      {id: "true", label: "Enabled"},
      {id: "false", label: "Disabled"}
    ]
  const columns = [
    {
      name: 'id',
      sortable: true,
      enableColumnFilterContextMenu: false,
      type: 'number',
    }, {
      name: 'username',
      header: 'Username',
      sortable: true,
      render: ({data}) => <Link to={"/user/edit/" + data.id}>{data.username}</Link>
    }, {
      name: 'fullName',
      header: 'Full Name',
      flex: 1,
      sortable: true,
    }, {
      name: 'enabled',
      header: 'Enabled',
      sortable: true,
      render: ({data}) => data.enabled ? <i className="bi-check text-success fs-2"/> :
        <i className="bi-x text-danger fs-2"/>,
      enableColumnFilterContextMenu: false,
      filterEditor: SelectFilter,
      filterEditorProps: {
        dataSource: statusFilterSources
      },
    }, {
      name: 'dateCreated',
      header: 'Date created',
      sortable: true,
      render: ({data}) => new Date(data.dateCreated).toLocaleString()
    }
  ]
  const filterValue = [
    {name: 'id', operator: 'eq', type: 'number', value: ''},
    {name: 'username', operator: 'contains', type: 'string', value: ''},
    {name: 'fullName', operator: 'contains', type: 'string', value: ''},
    {name: 'enabled', operator: 'eq', type: 'boolean', value: ''}
  ];
  return (
    <div>
      <ReactDataGrid
        idProperty="id"
        style={{minHeight: 550}}
        columns={columns}
        defaultSortInfo={{name: 'id', dir: 1}}
        dataSource={getData}
        defaultFilterValue={filterValue}
        pagination
        defaultLimit={10}
      />
    </div>
  );
};
export default UserGrid;

Common Layout Components

We can create components for our footer and navbar that can be reused from all of our pages. Create the following components under a components/layout folder:

Footer.jsx
import React from 'react';
const Footer = () => {
  return (
    <footer className="footer navbar-dark bg-dark fixed-bottom">
      <div className="container">
        <div className="row">
          <div className="col-md-4"></div>
          <div className="col-md-4">
            <p className="text-center text-muted mt-2">&copy;
              <a href="https://tucanoo.com" className="text-white">Tucanoo Solutions Ltd.</a>
            </p>
          </div>
        </div>
      </div>
    </footer>
  );
};
export default Footer;
Navbar.jsx
import React, {useContext} from 'react';
import {AuthContext} from "react-oauth2-code-pkce";
const Navbar = () => {
  const {token, logOut } = useContext(AuthContext);
  return (
    <nav className="navbar navbar-dark bg-dark fixed-top justify-content-between px-4">
      <a className="navbar-brand" href="/">Simple CRM</a>
      {token && <a className="mr-auto btn btn-sm btn-outline-light" href="/logout" onClick={() => logOut()} >Logout</a>}
    </nav>
  );
};
export default Navbar;

That’s all the individual components we need, we can focus on the outer pages, the containers.

Containers

Unauthenticated users reaching our page should be presented with the means to log in, so our initial container can be the Login page, at least a container for the login button that would redirect the user to our Authentication server. Under a new folder /containers/auth, create Login.jsx and insert the following:

import React, {useContext} from 'react';
import {AuthContext} from "react-oauth2-code-pkce";
import {Navigate} from "react-router";
const Login = () => {
  const {  token, login, } = useContext(AuthContext);
  if (token)
    return <Navigate to={ '/home' } replace={ true } />
  return (
    <div className="container" style={{marginTop:80}}>
      <div className="mb-5">
        <h1>Welcome to Simple CRM</h1>
        <h2>Customer Management made Simple</h2>
      </div>
      <div className="row">
        <div className="col-12 col-md-4 offset-md-4">
          <div className="card">
            <article className="card-body">
              <h4 className="card-title text-center mb-4 mt-1">Sign in</h4>
              <hr/>
              <a onClick={() => login()} className="btn btn-primary w-100">Login</a>
            </article>
          </div>
        </div>
      </div>
    </div>
  );
};
export default Login;

Once the user is logged in, they should then be presented with a Dashboard of sorts, at least a portal allowing them to see further navigation options. Under /containers, create a Dashboard.jsx and insert the following:

import React, {useContext} from 'react';
import {AuthContext} from "react-oauth2-code-pkce";
import {Link} from "react-router-dom";
const Dashboard = () => {
  const {idTokenData} = useContext(AuthContext);
  const isAdmin = idTokenData?.role === 'ADMIN';
  return (
    <div className="container" style={{marginTop: 80}}>
      <div>
        <h1>Welcome to simple crm</h1>
        <h2>Customer Management made Simple</h2>
      </div>
      <p className="mt-5"><Link to="/customer" className="btn btn-primary w-100">Manage Customers</Link></p>
      {isAdmin &&
        <p className="mt-5">
          <Link to="/user" className="btn btn-primary w-100">Manage Users</Link>
        </p>
      }
    </div>
  );
};
export default Dashboard;

User Pages / Containers

Under containers/user, create the following components. These wrap the individual user-centric components and provide functionality to interact with our back-end API.

Users.jsx
import React from 'react';
import {Link} from "react-router-dom";
import {useLocation} from "react-router";
import UserGrid from "../../components/user/UserGrid.jsx";
const Users = () => {
  const location = useLocation();
  return (
    <div className="container" style={{marginTop: 80}}>
      <h1 className="pb-2 border-bottom row">
        <span className="col-12 col-sm-6 pb-4">User List</span>
        <span className="col-12 col-sm-6 text-sm-end pb-4">
            <Link to="/user/create"
                  className="btn btn-outline-primary d-block d-sm-inline-block me-2">Create User</Link>
          <Link to="/" className="btn btn-primary d-block d-sm-inline-block">Back</Link>
        </span>
      </h1>
      {location.state?.message && <div className="alert alert-success">
        <h3>{location.state?.message}</h3>
      </div>}
      <div className="mt-5">
        <UserGrid/>
      </div>
    </div>
  );
};
export default Users;
CreateUser.jsx
import React, {useState} from 'react';
import {Link} from "react-router-dom";
import CreateUserForm from "../../components/user/CreateUserForm.jsx";
import axiosInstance from "../../network/API.js";
import {useNavigate} from "react-router";
import {USER_API_URL} from "../../app/config.js";
const CreateUser = () => {
  const navigate = useNavigate();
  const [errors, setErrors] = useState([]);
  const formSubmit = (data) => {
    axiosInstance.post(USER_API_URL + "/user", data)
      .then(response => {
        navigate("/user", {state: {message: "User created successfully"}})
      })
      .catch(error => {
        console.error(error)
        setErrors(["Something went wrong. Please try again later : " + error]);
      })
  }
  return (
    <div className="container" style={{marginTop: 80}}>
      <h1 className="pb-2 border-bottom row">
        <span className="col-sm pb-4">New User Details</span>
        <span className="col-12 col-sm-6 text-sm-end pb-4">
            <Link to="/user" className="btn btn-primary d-block d-sm-inline-block">Back to list</Link>
        </span>
      </h1>

      <div className="mt-5 card card-body bg-light">
        <CreateUserForm saveAction={formSubmit}/>
      </div>
    </div>
  );
};
export default CreateUser;
EditUser.jsx
import React, {useEffect, useState} from 'react';
import {Link} from "react-router-dom";
import axiosInstance from "../../network/API.js";
import {useNavigate, useParams} from "react-router";
import { USER_API_URL} from "../../app/config.js";
import EditUserForm from "../../components/user/EditUserForm.jsx";
const EditUser = () => {
  const navigate = useNavigate();
  const [errors, setErrors] = useState([]);
  const [loadError, setLoadError] = useState([]);
  const {id} = useParams();
  const [loading, setLoading] = useState(true);
  const [data, setData] = useState(null);
  const formSubmit = (data) => {
    axiosInstance.put(USER_API_URL + "/user", data)
      .then(response => {
        navigate("/user", {state: {message: "User saved successfully"}})
      })
      .catch(error => {
        console.error(error)
        setErrors(["Something went wrong. Please try again later : " + error]);
      })
  }

  useEffect(() => {
    function loadData() {
      setLoading(true);
      try {
        axiosInstance.get(USER_API_URL + "/user/" + id)
          .then(response => {
            setData(response.data)
          })
      } catch (error) {
        console.error(error);
        setLoadError(["Something went wrong. Please try again later."]);
      } finally {
        setLoading(false);
      }
    }
    loadData();
  }, []);
  let content = <div className="text-center">Loading...</div>;
  if (loadError)
    content = <div className="text-center">{loadError}</div>;
  if (!loading && data)
    content = <EditUserForm defaultValues={data} saveAction={formSubmit}/>;
  return (
    <div className="container" style={{marginTop: 80}}>
      <h1 className="pb-2 border-bottom row">
        <span className="col-sm pb-4">Edit User</span>
        <span className="col-12 col-sm-6 text-sm-end pb-4">
            <Link to="/user" className="btn btn-primary d-block d-sm-inline-block">Back to list</Link>
        </span>
      </h1>
      <div className="mt-5 card card-body bg-light">
        {content}
      </div>
    </div>
  );
};
export default EditUser;

Customer Pages / Containers

Now create similar containers for the customer-centric pages, under /containers/customer with the following pages:

CreateCustomer.jsx
import React, {useState} from 'react';
import {Link} from "react-router-dom";
import CustomerForm from "../../components/customer/CustomerForm.jsx";
import axiosInstance from "../../network/API.js";
import {useNavigate} from "react-router";
import {CUSTOMER_API_URL} from "../../app/config.js";
const CreateCustomer = () => {
  const navigate = useNavigate();
  const [errors, setErrors] = useState([]);
  const formSubmit = (data) => {
    axiosInstance.post(CUSTOMER_API_URL + "/customer", data)
      .then(response => {
        navigate( "/customer", {state: {message: "Customer created successfully"}})
      })
      .catch(error => {
        console.error(error)
        setErrors(["Something went wrong. Please try again later : " + error]);
      })
  }
  return (
    <div className="container" style={{marginTop:80}}>
      <h1 className="pb-2 border-bottom row">
        <span className="col-sm pb-4">New Customer Details</span>
        <span className="col-12 col-sm-6 text-sm-end pb-4">
            <Link to="/customer" className="btn btn-primary d-block d-sm-inline-block">Back to list</Link>
        </span>
      </h1>

      <div className="mt-5 card card-body bg-light">
        <CustomerForm saveAction={formSubmit}/>
      </div>
    </div>
  );
};
export default CreateCustomer;
Customers.jsx
import React, {useContext} from 'react';
import {AuthContext} from "react-oauth2-code-pkce";
import {Link} from "react-router-dom";
import {useLocation} from "react-router";
import CustomerGrid from "../../components/customer/CustomerGrid.jsx";
const Customers = () => {
  const location = useLocation();
  const {idTokenData} = useContext(AuthContext);
  const canCreateCustomers = ['ADMIN', 'USER'].includes(idTokenData?.role);
  return (
    <div className="container" style={{marginTop: 80}}>
      <h1 className="pb-2 border-bottom row">
        <span className="col-12 col-sm-6 pb-4">Customer List</span>
        <span className="col-12 col-sm-6 text-sm-end pb-4">
          {canCreateCustomers &&
            <Link to="/customer/create"
                  className="btn btn-outline-primary d-block d-sm-inline-block me-2">Create Customer</Link>
          }
          <Link to="/" className="btn btn-primary d-block d-sm-inline-block">Back</Link>
        </span>
      </h1>
      {location.state?.message && <div className="alert alert-success">
        <h3>{location.state?.message}</h3>
      </div>}
      <div className="mt-5">
        <CustomerGrid />
      </div>
    </div>
  );
};
export default Customers;
EditCustomer.jsx
import React, {useEffect, useState} from 'react';
import {Link} from "react-router-dom";
import CustomerForm from "../../components/customer/CustomerForm.jsx";
import axiosInstance from "../../network/API.js";
import {useNavigate, useParams} from "react-router";
import {CUSTOMER_API_URL} from "../../app/config.js";
const EditCustomer = () => {
  const navigate = useNavigate();
  const [errors, setErrors] = useState([]);
  const [loadError, setLoadError] = useState([]);
  const {id} = useParams();
  const [loading, setLoading] = useState(true);
  const [data, setData] = useState(null);
  const formSubmit = (data) => {
    axiosInstance.put(CUSTOMER_API_URL + "/customer", data)
      .then(response => {
        navigate("/customer", {state: {message: "Customer saved successfully"}})
      })
      .catch(error => {
        console.error(error)
        setErrors(["Something went wrong. Please try again later : " + error]);
      })
  }

  useEffect(() => {
    function loadData() {
      setLoading(true);
      try {
        axiosInstance.get(CUSTOMER_API_URL + "/customer/" + id)
          .then(response => {
            setData(response.data)
          })
      } catch (error) {
        console.error(error);
        setErrors(["Something went wrong. Please try again later."]);
      } finally {
        setLoading(false);
      }
    }
    loadData();
  }, []);
  let content = <div className="text-center">Loading...</div>;
  if (loadError)
    content = <div className="text-center">{loadError}</div>;
  if (!loading && data)
    content = <CustomerForm defaultValues={data} saveAction={formSubmit}/>;
  return (
    <div className="container" style={{marginTop: 80}}>
      <h1 className="pb-2 border-bottom row">
        <span className="col-sm pb-4">Edit Customer</span>
        <span className="col-12 col-sm-6 text-sm-end pb-4">
            <Link to="/customer" className="btn btn-primary d-block d-sm-inline-block">Back to list</Link>
        </span>
      </h1>
      <div className="mt-5 card card-body bg-light">
        {content}
      </div>
    </div>
  );
};
export default EditCustomer;
ShowCustomer.jsx
import React, {useEffect, useState} from 'react';
import {Link} from "react-router-dom";
import axiosInstance from "../../network/API.js";
import {useParams} from "react-router";
import {CUSTOMER_API_URL} from "../../app/config.js";
import CustomerDetail from "../../components/customer/CustomerDetail.jsx";
const ShowCustomer = () => {
  const {id} = useParams();
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);
  const [data, setData] = useState(null);
  useEffect(() => {
    function loadData() {
      setLoading(true);
      try {
        axiosInstance.get(CUSTOMER_API_URL + "/customer/" + id)
          .then(response => {
            setData(response.data)
          })
      } catch (error) {
        console.error(error);
        setError("Something went wrong. Please try again later.");
      } finally {
        setLoading(false);
      }
    }
    loadData();
  }, []);
  let content = <div className="text-center">Loading...</div>;
  if (error)
    content = <div className="text-center">{error}</div>;
  if (!loading && data)
    content = <CustomerDetail data={data}/>;
  return (
    <div className="container" style={{marginTop: 80}}>
      <h1 className="pb-2 border-bottom row">
        <span className="col-sm pb-4">Customer Details</span>
        <span className="col-12 col-sm-6 text-sm-end pb-4">
            <Link to="/customer" className="btn btn-primary d-block d-sm-inline-block">Back to list</Link>
        </span>
      </h1>

      <div className="mt-5 card card-body bg-light">
        {content}
      </div>
    </div>
  );
};
export default ShowCustomer;

Routing and Context

Now our containers and all the nested components are done, we just need to bring things together and establish routing rules between our pages and establish Auth context.

Before we define our routes, we need a couple of wrapper components to ensure only those authenticated and with necessary access roles can access the requested routes.

Under an app/routes folder, we’ll create a wrapper component for AdminRoutes, and for any other route requiring authentications. Add the following two components:

AdminRoute.jsx
import {Outlet} from "react-router-dom";
import {Navigate} from "react-router";
import {useContext} from "react";
import {AuthContext} from "react-oauth2-code-pkce";
const AdminRoute = () => {
  const {token, idTokenData} = useContext(AuthContext)
  if (token && idTokenData?.role === 'ADMIN')
    return <Outlet/>
  else
    return <Navigate to="/" replace/>
};
export default AdminRoute;
ProtectedRoute.jsx
import {Outlet} from "react-router-dom";
import {Navigate} from "react-router";
import {useContext} from "react";
import {AuthContext} from "react-oauth2-code-pkce";
const ProtectedRoute = () => {
  const { token } = useContext(AuthContext)
  return token ? <Outlet/> : <Navigate to="/" replace/>
};
export default ProtectedRoute;

Next, we can define the routes so open up /App.jsx and insert the following:

import Login from "./containers/auth/Login.jsx";
import Navbar from "./components/layout/Navbar.jsx";
import Footer from "./components/layout/Footer.jsx";
import {Navigate, Routes, useLocation, useNavigate} from "react-router";
import {Route} from "react-router-dom";
import ProtectedRoute from "./app/routes/ProtectedRoute.jsx";
import Dashboard from "./containers/Dashboard.jsx";
import {AUTH_TOKEN_NAME, LOGIN_URL, REDIRECT_URL, TOKEN_URL} from "./app/config.js";
import {AuthProvider} from "react-oauth2-code-pkce";
import OauthCallback from "./components/auth/OauthCallback.jsx";
import Customers from "./containers/customer/Customers.jsx";
import {setDefaultHeader} from "./network/API.js";
import CreateCustomer from "./containers/customer/CreateCustomer.jsx";
import ShowCustomer from "./containers/customer/ShowCustomer.jsx";
import EditCustomer from "./containers/customer/EditCustomer.jsx";
import Users from "./containers/user/Users.jsx";
import AdminRoute from "./app/routes/AdminRoute.jsx";
import "bootstrap-icons/font/bootstrap-icons.css";
import CreateUser from "./containers/user/CreateUser.jsx";
import EditUser from "./containers/user/EditUser.jsx";
function App() {
  history.navigate = useNavigate();
  history.location = useLocation();
  const authConfig = {
    clientId: 'frontend',
    authorizationEndpoint: LOGIN_URL,
    tokenEndpoint: TOKEN_URL,
    redirectUri: REDIRECT_URL,
    scope: 'openid',
    autoLogin: false,
    decodeToken: true,
    storageKeyPrefix: AUTH_TOKEN_NAME + '_',
    onRefreshTokenExpire: (event) => window.confirm('Session expired. Refresh page to continue using the site?') && event.login(),
    postLogin: () => {
      const token = localStorage.getItem(AUTH_TOKEN_NAME + "_token").substring(1, localStorage.getItem(AUTH_TOKEN_NAME + "_token").length - 1);
      // set default header for axios API calls
      setDefaultHeader('Authorization', 'Bearer ' + token)
    }
  }
  return (
    <>
      <AuthProvider authConfig={authConfig}>
        <Navbar/>
        <Routes>
          <Route path="/" element={<Login/>}/>
          <Route path="/auth/callback" element={<OauthCallback/>}/>
          <Route element={<ProtectedRoute/>}>
            <Route path="/home" element={<Dashboard/>}/>
            <Route path="/customer">
              <Route index element={<Customers/>}/>
              <Route path="create" element={<CreateCustomer/>}/>
              <Route path="edit/:id" element={<EditCustomer/>}/>
              <Route path=":id" element={<ShowCustomer/>}/>
            </Route>
          </Route>
          <Route element={<AdminRoute/>}>
            <Route path="/user">
              <Route index element={<Users/>}/>
              <Route path="create" element={<CreateUser/>}/>
              <Route path="edit/:id" element={<EditUser/>}/>
            </Route>
          </Route>
          <Route path="*" element={<Navigate to="/"/>}/>
        </Routes>
        <Footer/>
      </AuthProvider>
    </>
  )
}
export default App

Then to initialise react-router and establish our redux toolkit store, open main.jsx and insert the following:

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import {Provider} from "react-redux";
import {store} from "./app/store";
import 'bootstrap/dist/css/bootstrap.min.css';
import {BrowserRouter} from "react-router-dom";
ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <Provider store={store}>
      <BrowserRouter>
        <App/>
      </BrowserRouter>
    </Provider>
  </React.StrictMode>,
)

That is the bulk of the work complete for the front end, next, we will look at bringing everything together into a deployable package thanks to Docker. Continue reading or return to the contents

Dockerizing the project

By containerizing each service, Docker ensures consistent, isolated environments for development, testing, and production. This isolation simplifies dependency management and reduces conflicts between services. We’ll go through creating Dockerfiles for each individual service and a Docker Compose file to orchestrate their interactions. The compose file will also be responsible for setting up additional services that don’t need a full module, such as for distributed tracing. This setup highlights Docker’s role in facilitating efficient deployment and scaling of services, illustrating how it streamlines the development lifecycle and operational processes in a microservices ecosystem.

Since we have just completed the front-end app, let’s continue there and look at what we need to Dockerize this module.

Of course, there’s the Dockerfile, but also we will need supplemental configuration for Nginx, which is going to be used to serve the application itself within the container, and then a minor change to the Vite config. Start by creating the Dockerfile in the root folder of the front-end module:

Dockerfile
FROM node:18-alpine3.18 as build
ARG VITE_REDIRECT_URL=http://127.0.0.1
ARG GATEWAY_URL=http://localhost:8080
ENV VITE_LOGIN_URL=${GATEWAY_URL}/oauth2/authorize
ENV VITE_TOKEN_URL=${GATEWAY_URL}/oauth2/token
ENV VITE_USER_API_URL=${GATEWAY_URL}
ENV VITE_CUSTOMER_API_URL=$GATEWAY_URL
ENV VITE_REDIRECT_URL=$VITE_REDIRECT_URL
WORKDIR /app
COPY . /app
RUN npm i
RUN npm run build
FROM ubuntu
RUN apt-get update
RUN apt-get install nginx -y
COPY --from=build /app/dist /var/www/html/
COPY --from=build /app/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx","-g","daemon off;"]
nginx.conf

We need to override the default nginx configuration since our React application is a SPA (Single Page Application) which requires that all requests are routed through index.html

server {
    listen 80;
    server_name localhost;
      # Root directory where the build output is located
      root /var/www/html/;
      index index.html index.htm;
      location / {
        try_files $uri /index.html; # Fallback to index.html if no file found
      }
}
vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    host: true,
    strictPort: true,
    port: 80, // This is the port which we will use in docker
    watch: {
      usePolling: true
    }
  }
})

Spring Boot Services

Since we use the same Java version and Gradle build system for each of our other services, we can use the same Dockerfile for each, such as:

FROM eclipse-temurin:17.0.5_8-jre-focal as builder
WORKDIR extracted
ADD ./build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:17.0.5_8-jre-focal
WORKDIR application
COPY --from=builder extracted/dependencies/ ./
COPY --from=builder extracted/spring-boot-loader/ ./
COPY --from=builder extracted/snapshot-dependencies/ ./
COPY --from=builder extracted/application/ ./
EXPOSE 8761

ENTRYPOINT ["java","-Dspring.profiles.active=docker", "org.springframework.boot.loader.JarLauncher"]

Note, that this expects that each of the modules has already been built and a bootable jar file exists under the build/libs folder. The contents for each service are the same except for the highlighted port which is exposed. The ports for each service will be as such:

  • service-discover: EXPOSE 8761
  • gateway: EXPOSE 8080
  • centralised-config: EXPOSE 8888
  • customer-service: EXPOSE 7001
  • user-service: EXPOSE 7002

Docker Compose

Finally, to bring everything together we need to create a docker-compose.yml in our outermost project. Insert the following:

version: '2.1'
services:
  user-service:
    environment:
      - SPRING_PROFILES_ACTIVE=docker
      - REDIRECT_URL=http://localhost
    build: microservices/user-service
    mem_limit: 512m
#    ports:
#      - "7002:7002"
    healthcheck:
      test: [ "CMD", "curl", "-fs", "http://localhost:7002/actuator/health" ]
      interval: 5s
      timeout: 2s
      retries: 60
  customer-service:
    build: microservices/customer-service
    mem_limit: 512m
    environment:
      - SPRING_PROFILES_ACTIVE=docker
  service-discovery:
    environment:
      - SPRING_PROFILES_ACTIVE=docker
    build: service-discovery
    mem_limit: 512m
    ports:
      - "8761:8761"
  gateway:
    environment:
      - SPRING_PROFILES_ACTIVE=docker
    build: gateway
    mem_limit: 512m
    ports:
      - "8080:8080"
    depends_on:
      user-service:
        condition: service_healthy
  centralised-config:
    build: centralised-config
    mem_limit: 512m
    environment:
      - SPRING_PROFILES_ACTIVE=docker,native
    volumes:
      - ./config-files:/config-files
#    ports:
#      - "8888:8888"
  front-end:
    build:
      context: frontend
      dockerfile: Dockerfile
      args:
        GATEWAY_URL: http://localhost:8080
        VITE_REDIRECT_URL: http://localhost
    ports:
      - "80:80"
    depends_on:
      - gateway
  zipkin-server:
    image: openzipkin/zipkin:2.24.3
    restart: always
    mem_limit: 1024m
    environment:
      - STORAGE_TYPE=mem
    ports:
      - 9411:9411

You may note the additional service at the end which does not require any module to be defined, which gives us observability into our service architecture allowing us to monitor not only requests but even database queries generated for each request. This is thanks to the inclusion of micrometer.

In our config files, you’ll find tracking and zipkin entries which inform our services where to send such tracing data.

Running The Project

Once you have all the code in place you should just need to ensure that our topmost gradle project includes all the sub-modules, so in settings.gradle.kts there should be a section referring to all the modules as per:

include(
    ":api",
    ":gateway",
    ":centralised-config",
    ":service-discovery",
    ":microservices:user-service",
    ":microservices:customer-service"
)

In your IDE, within the Gradle tab, you should be able to assemble or run the bootJar task for the outer project which will in turn build all the sub-modules. If you have a problem at this point, try refreshing the Gradle project, or double-check that outer Gradle settings include all the submodules.

Once your services are all built, you should then attempt to deploy using the docker-compose file.

If you’re using Docker Desktop you should be ‘eventually’ able to see the full suite running:

CRM Microservices running in Docker Desktop

Otherwise, running “docker ps” via a terminal/command line should list the running containers:

CRM Microservices running shown via Docker PS command line

When you can confirm the apps are running, you should be able to access the front-end application at http://localhost (No port needed, as it’s running on port 80). Additionally for insight into the requests you can access Zipkin on port 9411, and the Eureka service discovery server on port 8761.

Final Thoughts

As we wrap up this tutorial, we sincerely hope you found it both informative and practical in your journey to mastering microservices architecture. The journey from conceptualizing to implementing a CRM system using Spring Boot microservices, complete with features like a cloud gateway, service discovery with Eureka, centralized configuration, and robust authentication, is an exciting adventure into modern software development.

If you found this tutorial helpful, please share it with your colleagues and peers who may also benefit from it. Collaboration and knowledge-sharing are key drivers in the tech community.

We believe that the skills and insights you’ve gained here are just the beginning. If you’re looking to delve deeper into microservices or need expert guidance to bring your projects to life, we invite you to contact us with any questions you may have.

As a next step, we encourage you to explore further. This tutorial is just the starting point for your microservices journey. Some next steps we’d recommend would be to:

  1. Enhance security. Focus on securing inter-service communication. For instance, ensure your config server is inaccessible without proper authentication. This step is crucial in safeguarding your services against unauthorized access.
  2. Introduce resilience. Investigate solutions like Resilience4J. This will help you handle service downtimes and enable your services to recover from errors autonomously. Implementing robust error handling and fallback mechanisms is key to maintaining system reliability.
  3. Migration to Kubernetes. While many concepts covered in this tutorial overlap with Kubernetes, it’s a broad topic in itself. We recommend getting comfortable with the basics we’ve discussed before delving into Kubernetes. Understanding these foundational concepts is essential for a smooth transition to more complex orchestration platforms

Below is a list of further reading links on each topic covered in this tutorial. These resources will enhance your understanding and keep you abreast of the latest trends and practices in microservices.

Take your next project to new heights with our specialized expertise and commitment to excellence in microservice development. Share, learn, and grow with us!

Return to contents

Share: