Mortgage Simulator API with Kotlin

Build a REST API that calculates mortgage payments using Spring Boot.

Introduction

30 Second Summary

When you apply for a home loan, the bank runs your numbers through a calculation engine to figure out your monthly payments. Behind the scenes, a backend service handles the math and stores every simulation result.

In this project, you will build a Mortgage Simulator REST API using Kotlin and Spring Boot that calculates monthly payments and generates full amortization schedules.

What You'll Build

A REST API that accepts loan parameters (principal, interest rate, term) and returns monthly payment amounts, month-by-month amortization breakdowns, and persisted simulation history.

By the end of this project, you'll have:

  • A working REST API with three endpoints for simulating mortgages, listing past simulations, and retrieving a simulation by ID.
  • A layered Spring Boot application (Controller, Service, Repository) with request validation and structured error handling.
  • Automated unit tests covering the mortgage calculation logic using JUnit 5 and MockK.
  • Secret Mission: A loan comparison endpoint that evaluates two mortgage configurations side-by-side and recommends which one saves more money.

Are there any prerequisites?

You need JDK 17 or higher and an IDE (IntelliJ IDEA or VS Code) installed on your machine. Step 1 walks you through scaffolding the project, so no prior Spring Boot experience is required.

This is Project 1 of 6 in the Full-Stack Engineer for Itaú series. It rebuilds your Kotlin and Spring Boot foundations before the series moves into microfrontends, AWS infrastructure, and event-driven architecture.

Before We Start

Before you write any mortgage calculation logic, you need a working project scaffold. Spring Initializr generates a pre-configured project with all the right dependencies, build tooling, and folder structure in seconds.

In this step, you will configure a Kotlin project on Spring Initializr, download it, and confirm it runs locally. This gives you a solid base to start building your mortgage simulator API.

In this step, get ready to:
  • Verify your JDK installation.
  • Configure and download a Spring Boot project from Spring Initializr.
  • Run the application to confirm the scaffold works.
Verify your JDK installation

Spring Boot 3.5.14 requires Java SDK 17 or higher. Let's confirm your JDK is ready.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then type Terminal (macOS) or cmd (Windows) and press Enter.
  • Check your Java version by running this command:
java -version

✔️ I see version 17 or higher

Your JDK is ready. You should see output like openjdk version "17.x.x" or higher. Move on to the next substep.

ⓧ I see a version lower than 17

You need JDK 17 or higher for this project. Download and install the latest JDK 17+ from the Oracle JDK downloads page or use Adoptium.

After installing, close and reopen your terminal, then run java -version again to confirm.

ⓧ Command not found

Java is not installed on your system. Download and install JDK 17+ from the Oracle JDK downloads page or use Adoptium.

After installing, close and reopen your terminal, then run java -version again to confirm version 17 or higher appears.

Configure and download from Spring Initializr

Spring Initializr is a web tool that generates pre-configured Spring Boot projects. You select your language, build tool, and dependencies, then download a ready-to-run project.

  • Open start.spring.io in your browser.
  • Set Project to Maven.
  • Set Language to Kotlin.
  • Set Spring Boot to 3.5.14.
  • Under Project Metadata, set Group to com.itau.
  • Set Artifact to mortgage-simulator.
  • Set Name to mortgage-simulator.
  • Set Description to Mortgage Simulator REST API.
  • Set Packaging to Jar.
  • Set Java to 17.
  • Click ADD DEPENDENCIES and add the following four dependencies:
  • Search for and select Spring Web.
  • Search for and select Spring Data JPA.
  • Search for and select H2 Database.
  • Search for and select Validation.
  • Click GENERATE to download the project as a .zip file.

What did Spring Initializr just do?

It generated a full Maven project pre-configured with Kotlin, a pom.xml containing your selected dependencies, a Maven wrapper (so you don't need Maven installed globally), and a main application class ready to boot.

  • Find the downloaded mortgage-simulator.zip file (usually in your Downloads folder).
  • Extract the zip file to your Desktop.
  • Open your IDE (IntelliJ IDEA or VS Code).
  • In your IDE, open the mortgage-simulator folder you just extracted.

IntelliJ IDEA import

If you are using IntelliJ IDEA, click File then Open, navigate to the mortgage-simulator folder, and select the pom.xml file. When prompted, choose Open as Project. IntelliJ will automatically import all Maven dependencies.

Configure the database and run the application

The scaffold needs a few database configuration lines before it can start. Spring Boot uses these properties to connect to the H2 in-memory database.

  • In your IDE, open src/main/resources/application.properties.
  • Replace everything in the file with the following configuration:
spring.application.name=mortgage-simulator
server.port=8080

# H2 Database
spring.datasource.url=jdbc:h2:mem:mortgagedb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

# H2 Console
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

What does this configuration do?

  • spring.datasource.url=jdbc:h2:mem:mortgagedb creates an in-memory database called mortgagedb that lives only while the app is running.
  • spring.jpa.hibernate.ddl-auto=create-drop automatically creates database tables on startup and drops them on shutdown.
  • spring.h2.console.enabled=true lets you browse the database in your browser at /h2-console.
  • Save application.properties.
  • Open a terminal inside your IDE or navigate to the mortgage-simulator folder in your terminal.
  • Start the application by running this command:

macOS

./mvnw spring-boot:run

Windows

mvnw.cmd spring-boot:run

What is mvnw?

The mvnw file is a Maven wrapper that Spring Initializr includes in your project. It downloads and runs the correct version of Maven automatically, so you don't need Maven installed globally on your machine.

The first run downloads dependencies, so it may take a minute. When it finishes, you should see a line like Started MortgageSimulatorApplication in the terminal output.

  • Open your browser and go to http://localhost:8080.

You should see a "Whitelabel Error Page" with a 404 status. This is expected because you haven't created any endpoints yet.

Application won't start?

  • If you see "Permission denied" on macOS, run chmod +x mvnw in your project folder first, then try again.
  • If the terminal says port 8080 is already in use, close any other application using that port and retry.
  • Make sure your terminal is inside the mortgage-simulator folder (where pom.xml lives).

help me debug my Spring Boot application startup error

✔️ Awesome, I've got everything!

Great. Make sure you have saved application.properties and the application is running on port 8080.

ⓧ I'd like to double check the full code

Here is the full application.properties file you should have:

spring.application.name=mortgage-simulator
server.port=8080

# H2 Database
spring.datasource.url=jdbc:h2:mem:mortgagedb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

# H2 Console
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

And here is the main application file at src/main/kotlin/com/itau/mortgagesimulator/MortgageSimulatorApplication.kt (auto-generated by Spring Initializr):

package com.itau.mortgagesimulator

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class MortgageSimulatorApplication

fun main(args: Array<String>) {
    runApplication<MortgageSimulatorApplication>(*args)
}

And here is the pom.xml that Spring Initializr generated:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.14</version>
        <relativePath/>
    </parent>
    <groupId>com.itau</groupId>
    <artifactId>mortgage-simulator</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>mortgage-simulator</name>
    <description>Mortgage Simulator REST API</description>
    <properties>
        <java.version>17</java.version>
        <kotlin.version>2.3.21</kotlin.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.module</groupId>
            <artifactId>jackson-module-kotlin</artifactId>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-reflect</artifactId>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-stdlib</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-test-junit5</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
        <testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.jetbrains.kotlin</groupId>
                <artifactId>kotlin-maven-plugin</artifactId>
                <configuration>
                    <args>
                        <arg>-Xjsr305=strict</arg>
                    </args>
                    <compilerPlugins>
                        <plugin>spring</plugin>
                        <plugin>jpa</plugin>
                    </compilerPlugins>
                </configuration>
                <dependencies>
                    <dependency>
                        <groupId>org.jetbrains.kotlin</groupId>
                        <artifactId>kotlin-maven-allopen</artifactId>
                        <version>${kotlin.version}</version>
                    </dependency>
                    <dependency>
                        <groupId>org.jetbrains.kotlin</groupId>
                        <artifactId>kotlin-maven-noarg</artifactId>
                        <version>${kotlin.version}</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
    </build>
</project>

Your project scaffold is running. Next up, you will create the domain model and database layer for storing mortgage simulations.

Create the Domain Model and Repository

Your Spring Boot scaffold is running and connected to an in-memory H2 database. Now you need to define what data your API actually works with.

Every REST API needs a domain model that represents its core business object. In this step, you'll create the JPA entity that maps a mortgage simulation to a database table, a repository for persistence, and DTOs (Data Transfer Objects) that shape what your API accepts and returns.

In this step, get ready to:
  • Create a JPA entity to represent mortgage simulations in the database.
  • Build a repository interface for database operations.
  • Define request and response DTOs with validation annotations.
Create the JPA entity and repository

The MortgageSimulation entity defines the shape of the data that gets stored in the database. Each field in the class maps to a column in an H2 table.

  • In your IDE's project explorer, create a new package called model inside src/main/kotlin/com/itau/mortgagesimulator/.
  • Create a new file called MortgageSimulation.kt inside the model package, then paste the following code:
package com.itau.mortgagesimulator.model

import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import java.math.BigDecimal
import java.time.LocalDateTime

// JPA entity that maps to the MORTGAGE_SIMULATION table in H2
@Entity
class MortgageSimulation(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    val principal: BigDecimal,
    val annualInterestRate: BigDecimal,
    val termInMonths: Int,
    val monthlyPayment: BigDecimal,
    val totalPaid: BigDecimal,
    val totalInterest: BigDecimal,
    val createdAt: LocalDateTime = LocalDateTime.now()
)

What does this code do?

The @Entity annotation tells Hibernate to create a database table from this class. @Id and @GeneratedValue mark the primary key as auto-incrementing.

Notice that id defaults to 0 and createdAt defaults to the current time. This means you only need to provide the loan parameters when creating a new simulation.

The jpa compiler plugin in your pom.xml generates the no-arg constructor that JPA requires, so you don't need to write one manually.

  • Save MortgageSimulation.kt.

Now create the repository that lets you save and retrieve simulations from the database.

  • Create a new package called repository inside src/main/kotlin/com/itau/mortgagesimulator/.
  • Create a new file called MortgageSimulationRepository.kt inside the repository package, then paste this code:
package com.itau.mortgagesimulator.repository

import com.itau.mortgagesimulator.model.MortgageSimulation
import org.springframework.data.jpa.repository.JpaRepository

// Spring Data JPA auto-implements CRUD operations for MortgageSimulation
interface MortgageSimulationRepository : JpaRepository<MortgageSimulation, Long>

What is a JpaRepository?

Spring Data JPA automatically generates all CRUD operations (save, find, delete) just from this interface declaration. You never write the implementation.

By extending JpaRepository<MortgageSimulation, Long>, you get methods like save(), findById(), and findAll() without writing any SQL.

  • Save MortgageSimulationRepository.kt.
  • Stop the running application in your terminal with Ctrl+C, then restart it by running:
mvnw.cmd spring-boot:run
  • Look at the console output. You should see Hibernate SQL statements including create table mortgage_simulation in the startup logs.

Don't see the create table statement?

  • Make sure spring.jpa.show-sql=true is set in your application.properties.
  • Check that your MortgageSimulation.kt file has the @Entity annotation on the class.
  • Verify the file is inside the com.itau.mortgagesimulator.model package so Spring Boot can scan it.

Why isn't Hibernate creating my table on startup?

Create the request DTO with validation

DTOs define the shape of data coming into and going out of your API. The request DTO uses Jakarta Validation annotations to reject bad input before it reaches your business logic.

  • Create a new package called dto inside src/main/kotlin/com/itau/mortgagesimulator/.
  • Create a new file called SimulationRequest.kt inside the dto package, then paste the following code:
package com.itau.mortgagesimulator.dto

import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Positive
import java.math.BigDecimal

// Request DTO with validation constraints to reject invalid loan parameters
data class SimulationRequest(
    @field:NotNull(message = "Principal is required")
    @field:Positive(message = "Principal must be positive")
    val principal: BigDecimal?,

    @field:NotNull(message = "Annual interest rate is required")
    @field:Positive(message = "Annual interest rate must be positive")
    @field:Max(value = 100, message = "Annual interest rate cannot exceed 100%")
    val annualInterestRate: BigDecimal?,

    @field:NotNull(message = "Term in months is required")
    @field:Min(value = 1, message = "Term must be at least 1 month")
    @field:Max(value = 600, message = "Term cannot exceed 600 months")
    val termInMonths: Int?
)

What do the validation annotations do?

Each @field: annotation tells Spring to validate the incoming JSON before your code runs. If someone sends a negative principal or a term of 0 months, Spring automatically rejects the request with a 400 error.

The fields are nullable (BigDecimal?, Int?) so that @field:NotNull can catch missing fields. If the fields were non-nullable Kotlin types, Jackson would throw a deserialization error before validation runs.

Why use @field: instead of just @NotNull?

In Kotlin data classes, annotations on constructor parameters target the parameter itself by default. The @field: prefix tells the compiler to place the annotation on the backing field, which is where Jakarta Validation looks for constraints.

  • Save SimulationRequest.kt.

Seeing unresolved reference errors?

  • Make sure the spring-boot-starter-validation dependency exists in your pom.xml. Without it, jakarta.validation.constraints classes are not available.
  • If your IDE still shows red underlines after adding the dependency, reload the Maven project (click the refresh icon in the Maven tool window or right-click the pom.xml and select Reload Maven Project).

Why are my Jakarta validation imports not resolving in my Kotlin project?

Create the response and error DTOs

The response DTOs define what your API sends back to callers. You'll create one for successful simulation results and one for structured error messages.

  • Create a new file called SimulationResponse.kt inside the dto package, then paste this code:
package com.itau.mortgagesimulator.dto

import java.math.BigDecimal
import java.time.LocalDateTime

// Response DTO containing the full simulation result
data class SimulationResponse(
    val id: Long,
    val principal: BigDecimal,
    val annualInterestRate: BigDecimal,
    val termInMonths: Int,
    val monthlyPayment: BigDecimal,
    val totalPaid: BigDecimal,
    val totalInterest: BigDecimal,
    val amortizationSchedule: List<AmortizationEntry>,
    val createdAt: LocalDateTime
)

// Each row in the month-by-month amortization breakdown
data class AmortizationEntry(
    val month: Int,
    val payment: BigDecimal,
    val principalPortion: BigDecimal,
    val interestPortion: BigDecimal,
    val remainingBalance: BigDecimal
)

What does this code do?

SimulationResponse wraps all the data the API returns after running a mortgage simulation. It includes the calculated monthly payment, totals, and a full month-by-month amortization schedule.

AmortizationEntry represents one row in the schedule. Each entry shows how much of that month's payment goes toward principal vs interest, plus the remaining loan balance.

  • Save SimulationResponse.kt.
  • Create another file called ErrorResponse.kt inside the same dto package, then paste this code:
package com.itau.mortgagesimulator.dto

import java.time.LocalDateTime

// Structured error response returned when validation fails or a resource is not found
data class ErrorResponse(
    val status: Int,
    val error: String,
    val message: String,
    val timestamp: LocalDateTime = LocalDateTime.now(),
    val fieldErrors: List<FieldError> = emptyList()
)

// Individual field-level validation error
data class FieldError(
    val field: String,
    val message: String
)

Why create a custom error response?

Spring Boot's default error responses expose internal details that you don't want callers to see. A custom ErrorResponse gives you control over the JSON structure your API returns on errors.

The fieldErrors list lets you report exactly which fields failed validation, so API consumers can show precise error messages.

  • Save ErrorResponse.kt.
  • Stop the running application with Ctrl+C and restart it by running:
mvnw.cmd spring-boot:run
  • Confirm the application starts without errors. You should see the Started MortgageSimulatorApplication message in the console.

Application fails to start?

  • Check for typos in your package declarations. Every file must start with the correct package line matching its folder path.
  • Ensure every import statement resolves. Missing imports on BigDecimal or LocalDateTime will cause compilation errors.
  • Verify that the dto package is inside com.itau.mortgagesimulator so Spring Boot's component scan can find it.

My Spring Boot application fails to compile after adding my DTO files. Help me debug.

✔️ Awesome, I've got everything!

Great. Make sure all five new files are saved before moving on.

ⓧ I'd like to double check the full code

Here are all the files you created in this step. Compare yours against these:

package com.itau.mortgagesimulator.model

import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import java.math.BigDecimal
import java.time.LocalDateTime

@Entity
class MortgageSimulation(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    val principal: BigDecimal,
    val annualInterestRate: BigDecimal,
    val termInMonths: Int,
    val monthlyPayment: BigDecimal,
    val totalPaid: BigDecimal,
    val totalInterest: BigDecimal,
    val createdAt: LocalDateTime = LocalDateTime.now()
)
package com.itau.mortgagesimulator.repository

import com.itau.mortgagesimulator.model.MortgageSimulation
import org.springframework.data.jpa.repository.JpaRepository

interface MortgageSimulationRepository : JpaRepository<MortgageSimulation, Long>
package com.itau.mortgagesimulator.dto

import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Positive
import java.math.BigDecimal

data class SimulationRequest(
    @field:NotNull(message = "Principal is required")
    @field:Positive(message = "Principal must be positive")
    val principal: BigDecimal?,

    @field:NotNull(message = "Annual interest rate is required")
    @field:Positive(message = "Annual interest rate must be positive")
    @field:Max(value = 100, message = "Annual interest rate cannot exceed 100%")
    val annualInterestRate: BigDecimal?,

    @field:NotNull(message = "Term in months is required")
    @field:Min(value = 1, message = "Term must be at least 1 month")
    @field:Max(value = 600, message = "Term cannot exceed 600 months")
    val termInMonths: Int?
)
package com.itau.mortgagesimulator.dto

import java.math.BigDecimal
import java.time.LocalDateTime

data class SimulationResponse(
    val id: Long,
    val principal: BigDecimal,
    val annualInterestRate: BigDecimal,
    val termInMonths: Int,
    val monthlyPayment: BigDecimal,
    val totalPaid: BigDecimal,
    val totalInterest: BigDecimal,
    val amortizationSchedule: List<AmortizationEntry>,
    val createdAt: LocalDateTime
)

data class AmortizationEntry(
    val month: Int,
    val payment: BigDecimal,
    val principalPortion: BigDecimal,
    val interestPortion: BigDecimal,
    val remainingBalance: BigDecimal
)
package com.itau.mortgagesimulator.dto

import java.time.LocalDateTime

data class ErrorResponse(
    val status: Int,
    val error: String,
    val message: String,
    val timestamp: LocalDateTime = LocalDateTime.now(),
    val fieldErrors: List<FieldError> = emptyList()
)

data class FieldError(
    val field: String,
    val message: String
)

Your domain model and DTOs are in place. Next up, you'll write the mortgage calculation service that uses this entity to simulate real loan payments.

Build the Mortgage Calculation Service

You have a solid data layer in place. Your entity, repository, and DTOs define the shape of your application. Now it is time to write the brain of the API.

In this step, you will build the service layer that performs the actual mortgage math. This is where the Price/French amortization formula lives. It is the same formula Brazilian banks like Itaú use to calculate fixed monthly payments on home loans.

In this step, get ready to:
  • Create a MortgageService class that calculates monthly payments using the Price formula.
  • Build a method that generates a full month-by-month amortization schedule.
  • Save each simulation to the H2 database and add query methods to retrieve them.
Create the service class with the simulate method

The service layer sits between the controller (which handles HTTP) and the repository (which handles the database). It contains all the business logic.

  • Create a new package folder called service inside src/main/kotlin/com/itau/mortgagesimulator/.
  • Create a new file called MortgageService.kt inside the service folder.
  • Start by adding the package declaration, imports, the class with constructor injection, and the main simulate() function by pasting this code:
package com.itau.mortgagesimulator.service

import com.itau.mortgagesimulator.dto.AmortizationEntry
import com.itau.mortgagesimulator.dto.SimulationRequest
import com.itau.mortgagesimulator.dto.SimulationResponse
import com.itau.mortgagesimulator.model.MortgageSimulation
import com.itau.mortgagesimulator.repository.MortgageSimulationRepository
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.MathContext
import java.math.RoundingMode

@Service
class MortgageService(private val repository: MortgageSimulationRepository) {

    fun simulate(request: SimulationRequest): SimulationResponse {
        // Extract validated fields from the request
        val principal = request.principal!!
        val annualRate = request.annualInterestRate!!
        val termMonths = request.termInMonths!!

        // Convert annual rate to monthly (divide by 12 months * 100 for percentage)
        val monthlyRate = annualRate.divide(BigDecimal("1200"), 10, RoundingMode.HALF_UP)
        val monthlyPayment = calculateMonthlyPayment(principal, monthlyRate, termMonths)
        val totalPaid = monthlyPayment.multiply(BigDecimal(termMonths))
        val totalInterest = totalPaid.subtract(principal)

        // Persist the simulation to the database
        val simulation = MortgageSimulation(
            principal = principal,
            annualInterestRate = annualRate,
            termInMonths = termMonths,
            monthlyPayment = monthlyPayment,
            totalPaid = totalPaid,
            totalInterest = totalInterest
        )
        val saved = repository.save(simulation)

        // Generate the full month-by-month schedule
        val schedule = generateAmortizationSchedule(principal, monthlyRate, monthlyPayment, termMonths)

        return SimulationResponse(
            id = saved.id,
            principal = saved.principal,
            annualInterestRate = saved.annualInterestRate,
            termInMonths = saved.termInMonths,
            monthlyPayment = saved.monthlyPayment,
            totalPaid = saved.totalPaid,
            totalInterest = saved.totalInterest,
            amortizationSchedule = schedule,
            createdAt = saved.createdAt
        )
    }

What does this code do?

The @Service annotation tells Spring Boot to manage this class as a bean. Spring automatically injects the MortgageSimulationRepository through the constructor.

The simulate() function is the main entry point. It extracts the loan parameters, runs the math, saves the result to the database, and builds a response with the full amortization schedule.

  • Below the simulate() function (still inside the class), add the two query methods:
    fun getAllSimulations(): List<SimulationResponse> {
        return repository.findAll().map { it.toResponse() }
    }

    fun getSimulationById(id: Long): SimulationResponse {
        val simulation = repository.findById(id)
            .orElseThrow { SimulationNotFoundException(id) }
        return simulation.toResponse()
    }

What do these methods do?

These two methods let the API retrieve saved simulations. getAllSimulations() fetches every simulation from the database. getSimulationById() looks up a single simulation by its ID and throws a custom exception if it does not exist.

You won't see a change yet. The next chunk adds the private methods that simulate() calls.

Implement the Price amortization formula

The Price/French amortization system keeps the monthly payment constant throughout the loan. Each month, the split between interest and principal changes, but the total payment stays the same.

  • Below getSimulationById(), add the private calculateMonthlyPayment() function by pasting this code:
    private fun calculateMonthlyPayment(
        principal: BigDecimal,
        monthlyRate: BigDecimal,
        termMonths: Int
    ): BigDecimal {
        // Handle edge case: zero interest means simple division
        if (monthlyRate.compareTo(BigDecimal.ZERO) == 0) {
            return principal.divide(BigDecimal(termMonths), 2, RoundingMode.HALF_UP)
        }
        // Price/French formula: M = P * [r(1+r)^n] / [(1+r)^n - 1]
        val mc = MathContext(20)
        val onePlusR = BigDecimal.ONE.add(monthlyRate)
        val onePlusRPowN = onePlusR.pow(termMonths, mc)
        val numerator = principal.multiply(monthlyRate).multiply(onePlusRPowN, mc)
        val denominator = onePlusRPowN.subtract(BigDecimal.ONE)
        return numerator.divide(denominator, 2, RoundingMode.HALF_UP)
    }

What is the Price/French formula?

The formula is M = P * [r(1+r)^n] / [(1+r)^n - 1], where P is the principal, r is the monthly interest rate, and n is the number of months.

The code uses BigDecimal instead of Double to avoid floating-point rounding errors. Financial calculations require exact precision. MathContext(20) ensures 20 digits of precision during intermediate calculations.

Why the Price system for Brazilian mortgages?

Brazilian banks overwhelmingly use the Price/French amortization system for mortgage credit (financiamento imobiliário). The fixed monthly payment makes it easier for borrowers to plan their budgets. The alternative SAC system (decreasing payments) is less common for residential mortgages.

You won't see a change yet. The next chunk builds the schedule that shows how each payment splits between interest and principal.

Build the amortization schedule and helper methods

The amortization schedule breaks down every single month of the loan. It shows how much of each payment goes toward interest, how much reduces the principal, and what balance remains.

  • Below calculateMonthlyPayment(), add the generateAmortizationSchedule() function by pasting this code:
    private fun generateAmortizationSchedule(
        principal: BigDecimal,
        monthlyRate: BigDecimal,
        monthlyPayment: BigDecimal,
        termMonths: Int
    ): List<AmortizationEntry> {
        val schedule = mutableListOf<AmortizationEntry>()
        var remainingBalance = principal

        for (month in 1..termMonths) {
            // Interest for this month is calculated on the remaining balance
            val interestPortion = remainingBalance.multiply(monthlyRate)
                .setScale(2, RoundingMode.HALF_UP)
            // The rest of the payment goes toward reducing the principal
            val principalPortion = monthlyPayment.subtract(interestPortion)
                .setScale(2, RoundingMode.HALF_UP)
            remainingBalance = remainingBalance.subtract(principalPortion)
                .setScale(2, RoundingMode.HALF_UP)

            // Prevent negative balance due to rounding on the final month
            if (remainingBalance < BigDecimal.ZERO) {
                remainingBalance = BigDecimal.ZERO
            }

            schedule.add(
                AmortizationEntry(
                    month = month,
                    payment = monthlyPayment,
                    principalPortion = principalPortion,
                    interestPortion = interestPortion,
                    remainingBalance = remainingBalance
                )
            )
        }
        return schedule
    }

What does this code do?

This function loops from month 1 to the final month. Each iteration calculates the interest on the current remaining balance, subtracts that from the fixed payment to get the principal portion, then reduces the balance.

Early months have high interest and low principal reduction. Later months flip: most of the payment goes toward principal. This is exactly what you see on a real mortgage statement.

  • Below generateAmortizationSchedule(), add the toResponse() extension function, close the class, and add the exception class by pasting this code:
    private fun MortgageSimulation.toResponse(): SimulationResponse {
        // Extension function to convert a database entity to a response DTO
        val monthlyRate = annualInterestRate.divide(BigDecimal("1200"), 10, RoundingMode.HALF_UP)
        val schedule = generateAmortizationSchedule(principal, monthlyRate, monthlyPayment, termInMonths)
        return SimulationResponse(
            id = id,
            principal = principal,
            annualInterestRate = annualInterestRate,
            termInMonths = termInMonths,
            monthlyPayment = monthlyPayment,
            totalPaid = totalPaid,
            totalInterest = totalInterest,
            amortizationSchedule = schedule,
            createdAt = createdAt
        )
    }
}

class SimulationNotFoundException(id: Long) : RuntimeException("Simulation with id $id not found")

What is an extension function?

In Kotlin, an extension function adds a method to an existing class without modifying it. Here, MortgageSimulation.toResponse() adds a conversion method to the entity class. This keeps the entity clean while providing a convenient way to transform database records into API responses.

  • Save MortgageService.kt.
  • In your terminal, stop the running application (press Ctrl+C) and restart it by running:
mvnw.cmd spring-boot:run
  • You should see the application start successfully with Started MortgageSimulatorApplication in the terminal output. This confirms the service class compiles and Spring registered it as a bean.

Seeing a compilation error?

Check that the closing brace } for the MortgageService class appears after toResponse() and before SimulationNotFoundException. The exception class lives outside the service class.

Make sure the package declaration is com.itau.mortgagesimulator.service and that the file is located in the matching folder path.

help me debug my MortgageService compilation error

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete MortgageService.kt file for comparison:

package com.itau.mortgagesimulator.service

import com.itau.mortgagesimulator.dto.AmortizationEntry
import com.itau.mortgagesimulator.dto.SimulationRequest
import com.itau.mortgagesimulator.dto.SimulationResponse
import com.itau.mortgagesimulator.model.MortgageSimulation
import com.itau.mortgagesimulator.repository.MortgageSimulationRepository
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.MathContext
import java.math.RoundingMode

@Service
class MortgageService(private val repository: MortgageSimulationRepository) {

    fun simulate(request: SimulationRequest): SimulationResponse {
        val principal = request.principal!!
        val annualRate = request.annualInterestRate!!
        val termMonths = request.termInMonths!!

        val monthlyRate = annualRate.divide(BigDecimal("1200"), 10, RoundingMode.HALF_UP)
        val monthlyPayment = calculateMonthlyPayment(principal, monthlyRate, termMonths)
        val totalPaid = monthlyPayment.multiply(BigDecimal(termMonths))
        val totalInterest = totalPaid.subtract(principal)

        val simulation = MortgageSimulation(
            principal = principal,
            annualInterestRate = annualRate,
            termInMonths = termMonths,
            monthlyPayment = monthlyPayment,
            totalPaid = totalPaid,
            totalInterest = totalInterest
        )
        val saved = repository.save(simulation)

        val schedule = generateAmortizationSchedule(principal, monthlyRate, monthlyPayment, termMonths)

        return SimulationResponse(
            id = saved.id,
            principal = saved.principal,
            annualInterestRate = saved.annualInterestRate,
            termInMonths = saved.termInMonths,
            monthlyPayment = saved.monthlyPayment,
            totalPaid = saved.totalPaid,
            totalInterest = saved.totalInterest,
            amortizationSchedule = schedule,
            createdAt = saved.createdAt
        )
    }

    fun getAllSimulations(): List<SimulationResponse> {
        return repository.findAll().map { it.toResponse() }
    }

    fun getSimulationById(id: Long): SimulationResponse {
        val simulation = repository.findById(id)
            .orElseThrow { SimulationNotFoundException(id) }
        return simulation.toResponse()
    }

    private fun calculateMonthlyPayment(
        principal: BigDecimal,
        monthlyRate: BigDecimal,
        termMonths: Int
    ): BigDecimal {
        if (monthlyRate.compareTo(BigDecimal.ZERO) == 0) {
            return principal.divide(BigDecimal(termMonths), 2, RoundingMode.HALF_UP)
        }
        val mc = MathContext(20)
        val onePlusR = BigDecimal.ONE.add(monthlyRate)
        val onePlusRPowN = onePlusR.pow(termMonths, mc)
        val numerator = principal.multiply(monthlyRate).multiply(onePlusRPowN, mc)
        val denominator = onePlusRPowN.subtract(BigDecimal.ONE)
        return numerator.divide(denominator, 2, RoundingMode.HALF_UP)
    }

    private fun generateAmortizationSchedule(
        principal: BigDecimal,
        monthlyRate: BigDecimal,
        monthlyPayment: BigDecimal,
        termMonths: Int
    ): List<AmortizationEntry> {
        val schedule = mutableListOf<AmortizationEntry>()
        var remainingBalance = principal

        for (month in 1..termMonths) {
            val interestPortion = remainingBalance.multiply(monthlyRate)
                .setScale(2, RoundingMode.HALF_UP)
            val principalPortion = monthlyPayment.subtract(interestPortion)
                .setScale(2, RoundingMode.HALF_UP)
            remainingBalance = remainingBalance.subtract(principalPortion)
                .setScale(2, RoundingMode.HALF_UP)

            if (remainingBalance < BigDecimal.ZERO) {
                remainingBalance = BigDecimal.ZERO
            }

            schedule.add(
                AmortizationEntry(
                    month = month,
                    payment = monthlyPayment,
                    principalPortion = principalPortion,
                    interestPortion = interestPortion,
                    remainingBalance = remainingBalance
                )
            )
        }
        return schedule
    }

    private fun MortgageSimulation.toResponse(): SimulationResponse {
        val monthlyRate = annualInterestRate.divide(BigDecimal("1200"), 10, RoundingMode.HALF_UP)
        val schedule = generateAmortizationSchedule(principal, monthlyRate, monthlyPayment, termInMonths)
        return SimulationResponse(
            id = id,
            principal = principal,
            annualInterestRate = annualInterestRate,
            termInMonths = termInMonths,
            monthlyPayment = monthlyPayment,
            totalPaid = totalPaid,
            totalInterest = totalInterest,
            amortizationSchedule = schedule,
            createdAt = createdAt
        )
    }
}

class SimulationNotFoundException(id: Long) : RuntimeException("Simulation with id $id not found")

Your service layer is complete. The mortgage math is implemented, simulations are persisted to the H2 database, and query methods are ready to retrieve them. Next up, you will create the REST controller that exposes these methods as HTTP endpoints.

Create the REST Controller and Error Handling

Your service layer is complete with all the mortgage math and database persistence logic. Now you need to expose that logic to the outside world through HTTP endpoints.

In this step, you will create a REST controller that maps HTTP requests to your service methods, and a global exception handler that catches errors and returns structured JSON responses instead of stack traces.

In this step, get ready to:
  • Create a REST controller with three endpoints for simulating, listing, and fetching mortgages.
  • Build a global exception handler that returns structured error responses.
  • Test all endpoints using curl to confirm the API works end to end.
Create the MortgageController

The controller is the entry point for all HTTP traffic. It delegates work to the service layer and wraps results in proper HTTP responses.

  • Create a new file at src/main/kotlin/com/itau/mortgagesimulator/controller/MortgageController.kt.
  • Paste the following code into the file:
package com.itau.mortgagesimulator.controller

import com.itau.mortgagesimulator.dto.SimulationRequest
import com.itau.mortgagesimulator.dto.SimulationResponse
import com.itau.mortgagesimulator.service.MortgageService
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/v1/mortgages")
class MortgageController(private val mortgageService: MortgageService) {

    // POST /api/v1/mortgages/simulate - run a new mortgage simulation
    @PostMapping("/simulate")
    fun simulate(@Valid @RequestBody request: SimulationRequest): ResponseEntity<SimulationResponse> {
        val result = mortgageService.simulate(request)
        return ResponseEntity.status(HttpStatus.CREATED).body(result)
    }

    // GET /api/v1/mortgages - list all past simulations
    @GetMapping
    fun getAllSimulations(): ResponseEntity<List<SimulationResponse>> {
        return ResponseEntity.ok(mortgageService.getAllSimulations())
    }

    // GET /api/v1/mortgages/{id} - fetch a single simulation by ID
    @GetMapping("/{id}")
    fun getSimulationById(@PathVariable id: Long): ResponseEntity<SimulationResponse> {
        return ResponseEntity.ok(mortgageService.getSimulationById(id))
    }
}

What does this code do?

@RestController tells Spring this class handles HTTP requests and automatically serializes return values to JSON.

@RequestMapping("/api/v1/mortgages") sets the base URL path for all endpoints in this controller. Every route starts with /api/v1/mortgages.

The @Valid annotation on the simulate method triggers the Jakarta Validation constraints you defined on SimulationRequest. If any field fails validation, Spring throws a MethodArgumentNotValidException before your code runs.

  • Save MortgageController.kt.

Why @RestController instead of @Controller?

@Controller is designed for server-rendered HTML views. @RestController combines @Controller and @ResponseBody, which means every method automatically serializes its return value to JSON. For a REST API, this is what you want.

Create the GlobalExceptionHandler

Without a global exception handler, Spring returns its default error page when something goes wrong. That is not useful for API consumers who expect structured JSON.

  • Create a new file at src/main/kotlin/com/itau/mortgagesimulator/exception/GlobalExceptionHandler.kt.
  • Paste the following code into the file:
package com.itau.mortgagesimulator.exception

import com.itau.mortgagesimulator.dto.ErrorResponse
import com.itau.mortgagesimulator.dto.FieldError
import com.itau.mortgagesimulator.service.SimulationNotFoundException
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

@RestControllerAdvice
class GlobalExceptionHandler {

    // Handle validation errors from @Valid annotations
    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidationErrors(ex: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> {
        val fieldErrors = ex.bindingResult.fieldErrors.map { error ->
            FieldError(
                field = error.field,
                message = error.defaultMessage ?: "Invalid value"
            )
        }
        val response = ErrorResponse(
            status = HttpStatus.BAD_REQUEST.value(),
            error = "Validation Failed",
            message = "Request contains invalid fields",
            fieldErrors = fieldErrors
        )
        return ResponseEntity.badRequest().body(response)
    }

    // Handle simulation-not-found errors
    @ExceptionHandler(SimulationNotFoundException::class)
    fun handleNotFound(ex: SimulationNotFoundException): ResponseEntity<ErrorResponse> {
        val response = ErrorResponse(
            status = HttpStatus.NOT_FOUND.value(),
            error = "Not Found",
            message = ex.message ?: "Resource not found"
        )
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response)
    }

    // Catch-all for unexpected errors
    @ExceptionHandler(Exception::class)
    fun handleGenericException(ex: Exception): ResponseEntity<ErrorResponse> {
        val response = ErrorResponse(
            status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
            error = "Internal Server Error",
            message = "An unexpected error occurred"
        )
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response)
    }
}

What does this code do?

@RestControllerAdvice marks this class as a global exception handler that applies to every controller in the application. Any unhandled exception thrown from a controller method is routed here.

  • handleValidationErrors catches MethodArgumentNotValidException (triggered by @Valid) and extracts field-level error messages into a clean JSON response.
  • handleNotFound catches the custom SimulationNotFoundException from your service layer and returns a 404.
  • handleGenericException is a catch-all safety net for any unexpected errors, returning a 500 without leaking internal details.
  • Save GlobalExceptionHandler.kt.

Seeing an import error on SimulationNotFoundException?

Make sure SimulationNotFoundException is defined in your MortgageService.kt file. It lives at the bottom of that file as a standalone class. The import path should be com.itau.mortgagesimulator.service.SimulationNotFoundException.

Help me fix import errors in my GlobalExceptionHandler

Test the endpoints with curl

Your controller and exception handler are in place. Time to restart the application and confirm all three endpoints work correctly.

  • Stop the running application if it is still active (press Ctrl+C in the terminal).
  • Restart the application by running:
mvnw.cmd spring-boot:run
  • Wait until you see a line containing Started MortgageSimulatorApplication in the terminal output.
  • Open a second terminal window. Test the simulate endpoint by running:
curl -X POST http://localhost:8080/api/v1/mortgages/simulate -H "Content-Type: application/json" -d "{\"principal\": 200000, \"annualInterestRate\": 12, \"termInMonths\": 360}"

What should I see?

You should see a JSON response containing monthlyPayment, totalPaid, totalInterest, and an amortizationSchedule array with 360 entries. The monthlyPayment value should be 2057.23.

  • Test the list endpoint by running:
curl http://localhost:8080/api/v1/mortgages
  • You should see a JSON array containing the simulation you just created.
  • Test the get-by-ID endpoint by running:
curl http://localhost:8080/api/v1/mortgages/1
  • You should see the same simulation response with id value 1.
  • Test the validation error handling by sending an invalid request:
curl -X POST http://localhost:8080/api/v1/mortgages/simulate -H "Content-Type: application/json" -d "{\"principal\": -100, \"annualInterestRate\": null, \"termInMonths\": 0}"

What should I see?

You should see a structured JSON error response with "error": "Validation Failed" and a fieldErrors array listing which fields are invalid and why.

  • Test the not-found error handling by requesting a non-existent ID:
curl http://localhost:8080/api/v1/mortgages/999
  • You should see a JSON response with "error": "Not Found" and a message stating the simulation was not found.

Getting a 404 on all endpoints?

Make sure your MortgageController.kt file is in the controller package under com.itau.mortgagesimulator. Spring Boot auto-scans packages starting from the package where your @SpringBootApplication class lives. If the controller is outside that package hierarchy, Spring will not detect it.

Help me debug why my Spring Boot controller endpoints return 404

✔️ Awesome, I've got everything!

Great. Double check that both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full MortgageController.kt:

package com.itau.mortgagesimulator.controller

import com.itau.mortgagesimulator.dto.SimulationRequest
import com.itau.mortgagesimulator.dto.SimulationResponse
import com.itau.mortgagesimulator.service.MortgageService
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/v1/mortgages")
class MortgageController(private val mortgageService: MortgageService) {

    @PostMapping("/simulate")
    fun simulate(@Valid @RequestBody request: SimulationRequest): ResponseEntity<SimulationResponse> {
        val result = mortgageService.simulate(request)
        return ResponseEntity.status(HttpStatus.CREATED).body(result)
    }

    @GetMapping
    fun getAllSimulations(): ResponseEntity<List<SimulationResponse>> {
        return ResponseEntity.ok(mortgageService.getAllSimulations())
    }

    @GetMapping("/{id}")
    fun getSimulationById(@PathVariable id: Long): ResponseEntity<SimulationResponse> {
        return ResponseEntity.ok(mortgageService.getSimulationById(id))
    }
}

Here is the full GlobalExceptionHandler.kt:

package com.itau.mortgagesimulator.exception

import com.itau.mortgagesimulator.dto.ErrorResponse
import com.itau.mortgagesimulator.dto.FieldError
import com.itau.mortgagesimulator.service.SimulationNotFoundException
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidationErrors(ex: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> {
        val fieldErrors = ex.bindingResult.fieldErrors.map { error ->
            FieldError(
                field = error.field,
                message = error.defaultMessage ?: "Invalid value"
            )
        }
        val response = ErrorResponse(
            status = HttpStatus.BAD_REQUEST.value(),
            error = "Validation Failed",
            message = "Request contains invalid fields",
            fieldErrors = fieldErrors
        )
        return ResponseEntity.badRequest().body(response)
    }

    @ExceptionHandler(SimulationNotFoundException::class)
    fun handleNotFound(ex: SimulationNotFoundException): ResponseEntity<ErrorResponse> {
        val response = ErrorResponse(
            status = HttpStatus.NOT_FOUND.value(),
            error = "Not Found",
            message = ex.message ?: "Resource not found"
        )
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response)
    }

    @ExceptionHandler(Exception::class)
    fun handleGenericException(ex: Exception): ResponseEntity<ErrorResponse> {
        val response = ErrorResponse(
            status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
            error = "Internal Server Error",
            message = "An unexpected error occurred"
        )
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response)
    }
}

Your API is live and responding to requests with proper validation and error handling. Next up, you will write unit tests to verify the mortgage math and controller behavior without needing to boot the full application.

Write Unit Tests with MockK

Your REST API is live and responding to requests. But how do you know the mortgage math is correct without manually testing every edge case?

Automated tests answer that question. In this step, you'll write unit tests that verify your service layer's calculations and integration tests that confirm your controller responds correctly to valid and invalid requests.

In this step, get ready to:
  • Add MockK and SpringMockK test dependencies to your project.
  • Write unit tests for the mortgage calculation service.
  • Write controller tests using @WebMvcTest and run the full test suite.
Add MockK and SpringMockK test dependencies

Your project already includes spring-boot-starter-test for JUnit 5 support. To mock dependencies in Kotlin-idiomatic style, you need two additional libraries.

Why MockK instead of Mockito?

Spring Boot's official Kotlin documentation recommends MockK as the mocking library for Kotlin projects. MockK understands Kotlin language features like data classes, coroutines, and extension functions natively.

SpringMockK provides the @MockkBean annotation, which is the Kotlin equivalent of Spring's @MockBean. It integrates MockK directly into the Spring test context.

  • Open pom.xml in your IDE.
  • Find the spring-boot-starter-test dependency block (near the bottom of the <dependencies> section).
  • Add the following two dependencies directly below it, before the kotlin-test-junit5 dependency:
<dependency>
    <groupId>io.mockk</groupId>
    <artifactId>mockk-jvm</artifactId>
    <version>1.13.16</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>com.ninja-squad</groupId>
    <artifactId>springmockk</artifactId>
    <version>4.0.2</version>
    <scope>test</scope>
</dependency>

What do these dependencies do?

  • mockk-jvm 1.13.16 is the core MockK library that lets you create mock objects, capture arguments, and define fake return values in your tests.
  • springmockk 4.0.2 bridges MockK with the Spring test context so you can use @MockkBean to replace real beans with mocks during integration tests.
  • Both use <scope>test</scope> so they're only available during testing, not in your production build.
  • Save pom.xml.
  • Your IDE should automatically download the new dependencies. If it doesn't, right-click the project and select Reload Maven Project (IntelliJ) or re-run the Maven sync.

Dependencies not resolving?

Make sure you placed the new dependencies inside the <dependencies> block, not inside <build>. Check that the XML tags are properly closed.

Help me troubleshoot my Maven dependencies

✔️ Awesome, I've got everything!

Great. Double check you've saved pom.xml before moving on.

ⓧ I'd like to double check the full code

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.14</version>
        <relativePath/>
    </parent>
    <groupId>com.itau</groupId>
    <artifactId>mortgage-simulator</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>mortgage-simulator</name>
    <description>Mortgage Simulator REST API</description>
    <properties>
        <java.version>17</java.version>
        <kotlin.version>2.3.21</kotlin.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.module</groupId>
            <artifactId>jackson-module-kotlin</artifactId>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-reflect</artifactId>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-stdlib</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.mockk</groupId>
            <artifactId>mockk-jvm</artifactId>
            <version>1.13.16</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.ninja-squad</groupId>
            <artifactId>springmockk</artifactId>
            <version>4.0.2</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-test-junit5</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
        <testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.jetbrains.kotlin</groupId>
                <artifactId>kotlin-maven-plugin</artifactId>
                <configuration>
                    <args>
                        <arg>-Xjsr305=strict</arg>
                    </args>
                    <compilerPlugins>
                        <plugin>spring</plugin>
                        <plugin>jpa</plugin>
                    </compilerPlugins>
                </configuration>
                <dependencies>
                    <dependency>
                        <groupId>org.jetbrains.kotlin</groupId>
                        <artifactId>kotlin-maven-allopen</artifactId>
                        <version>${kotlin.version}</version>
                    </dependency>
                    <dependency>
                        <groupId>org.jetbrains.kotlin</groupId>
                        <artifactId>kotlin-maven-noarg</artifactId>
                        <version>${kotlin.version}</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
    </build>
</project>
Write unit tests for the service layer

Unit tests for the service layer verify that your mortgage math is correct without needing a running database or Spring context. You'll mock the repository so the tests focus purely on calculation logic.

  • Create a new file at src/test/kotlin/com/itau/mortgagesimulator/service/MortgageServiceTest.kt.
  • Copy the following test class into it:
package com.itau.mortgagesimulator.service

import com.itau.mortgagesimulator.dto.SimulationRequest
import com.itau.mortgagesimulator.model.MortgageSimulation
import com.itau.mortgagesimulator.repository.MortgageSimulationRepository
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.math.BigDecimal
import java.util.Optional

class MortgageServiceTest {

    // Create a mock repository instead of a real database connection
    private val repository = mockk<MortgageSimulationRepository>()
    private val service = MortgageService(repository)

    @Test
    fun `should calculate correct monthly payment for standard loan`() {
        // Capture the simulation object passed to repository.save()
        val simulationSlot = slot<MortgageSimulation>()
        every { repository.save(capture(simulationSlot)) } answers {
            simulationSlot.captured.let {
                MortgageSimulation(
                    id = 1,
                    principal = it.principal,
                    annualInterestRate = it.annualInterestRate,
                    termInMonths = it.termInMonths,
                    monthlyPayment = it.monthlyPayment,
                    totalPaid = it.totalPaid,
                    totalInterest = it.totalInterest
                )
            }
        }

        // Simulate a R$200,000 loan at 12% for 30 years
        val request = SimulationRequest(
            principal = BigDecimal("200000"),
            annualInterestRate = BigDecimal("12"),
            termInMonths = 360
        )

        val result = service.simulate(request)

        // Verify the Price formula produces the expected monthly payment
        assertEquals(BigDecimal("2057.23"), result.monthlyPayment)
        assertEquals(360, result.amortizationSchedule.size)
        assertTrue(result.totalInterest > BigDecimal.ZERO)
    }

    @Test
    fun `should throw exception when simulation not found`() {
        // Return empty Optional to simulate a missing record
        every { repository.findById(999L) } returns Optional.empty()

        assertThrows<SimulationNotFoundException> {
            service.getSimulationById(999L)
        }
    }

    @Test
    fun `should generate amortization schedule with decreasing balance`() {
        // Capture and return a fake saved entity
        val simulationSlot = slot<MortgageSimulation>()
        every { repository.save(capture(simulationSlot)) } answers {
            simulationSlot.captured.let {
                MortgageSimulation(
                    id = 1,
                    principal = it.principal,
                    annualInterestRate = it.annualInterestRate,
                    termInMonths = it.termInMonths,
                    monthlyPayment = it.monthlyPayment,
                    totalPaid = it.totalPaid,
                    totalInterest = it.totalInterest
                )
            }
        }

        // Simulate a R$100,000 loan at 10% for 12 months
        val request = SimulationRequest(
            principal = BigDecimal("100000"),
            annualInterestRate = BigDecimal("10"),
            termInMonths = 12
        )

        val result = service.simulate(request)

        // Verify each month's balance decreases
        val schedule = result.amortizationSchedule
        for (i in 1 until schedule.size) {
            assertTrue(schedule[i].remainingBalance <= schedule[i - 1].remainingBalance)
        }
        assertEquals(BigDecimal.ZERO, schedule.last().remainingBalance)
    }
}

What do these tests verify?

  • The first test verifies the Price formula math. A R$200,000 loan at 12% annual interest over 360 months should produce a monthly payment of R$2,057.23.
  • The second test confirms that requesting a non-existent simulation ID throws SimulationNotFoundException.
  • The third test checks that the amortization schedule correctly reduces the balance each month until it reaches zero.

Notice how mockk<MortgageSimulationRepository>() creates a fake repository. The slot and capture pattern lets you intercept what the service passes to repository.save() and return it with a fake ID attached.

  • Save MortgageServiceTest.kt.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved at the correct path inside src/test/kotlin/com/itau/mortgagesimulator/service/.

ⓧ I'd like to double check the full code

package com.itau.mortgagesimulator.service

import com.itau.mortgagesimulator.dto.SimulationRequest
import com.itau.mortgagesimulator.model.MortgageSimulation
import com.itau.mortgagesimulator.repository.MortgageSimulationRepository
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.math.BigDecimal
import java.util.Optional

class MortgageServiceTest {

    private val repository = mockk<MortgageSimulationRepository>()
    private val service = MortgageService(repository)

    @Test
    fun `should calculate correct monthly payment for standard loan`() {
        val simulationSlot = slot<MortgageSimulation>()
        every { repository.save(capture(simulationSlot)) } answers {
            simulationSlot.captured.let {
                MortgageSimulation(
                    id = 1,
                    principal = it.principal,
                    annualInterestRate = it.annualInterestRate,
                    termInMonths = it.termInMonths,
                    monthlyPayment = it.monthlyPayment,
                    totalPaid = it.totalPaid,
                    totalInterest = it.totalInterest
                )
            }
        }

        val request = SimulationRequest(
            principal = BigDecimal("200000"),
            annualInterestRate = BigDecimal("12"),
            termInMonths = 360
        )

        val result = service.simulate(request)

        assertEquals(BigDecimal("2057.23"), result.monthlyPayment)
        assertEquals(360, result.amortizationSchedule.size)
        assertTrue(result.totalInterest > BigDecimal.ZERO)
    }

    @Test
    fun `should throw exception when simulation not found`() {
        every { repository.findById(999L) } returns Optional.empty()

        assertThrows<SimulationNotFoundException> {
            service.getSimulationById(999L)
        }
    }

    @Test
    fun `should generate amortization schedule with decreasing balance`() {
        val simulationSlot = slot<MortgageSimulation>()
        every { repository.save(capture(simulationSlot)) } answers {
            simulationSlot.captured.let {
                MortgageSimulation(
                    id = 1,
                    principal = it.principal,
                    annualInterestRate = it.annualInterestRate,
                    termInMonths = it.termInMonths,
                    monthlyPayment = it.monthlyPayment,
                    totalPaid = it.totalPaid,
                    totalInterest = it.totalInterest
                )
            }
        }

        val request = SimulationRequest(
            principal = BigDecimal("100000"),
            annualInterestRate = BigDecimal("10"),
            termInMonths = 12
        )

        val result = service.simulate(request)

        val schedule = result.amortizationSchedule
        for (i in 1 until schedule.size) {
            assertTrue(schedule[i].remainingBalance <= schedule[i - 1].remainingBalance)
        }
        assertEquals(BigDecimal.ZERO, schedule.last().remainingBalance)
    }
}
Write controller tests with @WebMvcTest

Controller tests verify that your endpoints respond with the correct HTTP status codes and JSON structure. The @WebMvcTest annotation loads only the web layer (no database, no full application context), making these tests fast and focused.

  • Create a new file at src/test/kotlin/com/itau/mortgagesimulator/controller/MortgageControllerTest.kt.
  • Copy the following test class into it:
package com.itau.mortgagesimulator.controller

import com.fasterxml.jackson.databind.ObjectMapper
import com.itau.mortgagesimulator.dto.AmortizationEntry
import com.itau.mortgagesimulator.dto.SimulationRequest
import com.itau.mortgagesimulator.dto.SimulationResponse
import com.itau.mortgagesimulator.service.MortgageService
import com.itau.mortgagesimulator.service.SimulationNotFoundException
import com.ninjasquad.springmockk.MockkBean
import io.mockk.every
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.math.BigDecimal
import java.time.LocalDateTime

@WebMvcTest(MortgageController::class)
class MortgageControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    // Replace the real MortgageService bean with a MockK mock
    @MockkBean
    private lateinit var mortgageService: MortgageService

    @Test
    fun `POST simulate should return 201 with simulation result`() {
        val request = SimulationRequest(
            principal = BigDecimal("200000"),
            annualInterestRate = BigDecimal("12"),
            termInMonths = 360
        )
        val response = SimulationResponse(
            id = 1,
            principal = BigDecimal("200000"),
            annualInterestRate = BigDecimal("12"),
            termInMonths = 360,
            monthlyPayment = BigDecimal("2057.23"),
            totalPaid = BigDecimal("740602.80"),
            totalInterest = BigDecimal("540602.80"),
            amortizationSchedule = listOf(
                AmortizationEntry(1, BigDecimal("2057.23"), BigDecimal("57.23"), BigDecimal("2000.00"), BigDecimal("199942.77"))
            ),
            createdAt = LocalDateTime.now()
        )

        // Tell the mock to return our prepared response
        every { mortgageService.simulate(any()) } returns response

        mockMvc.perform(
            post("/api/v1/mortgages/simulate")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
        )
            .andExpect(status().isCreated)
            .andExpect(jsonPath("$.monthlyPayment").value(2057.23))
            .andExpect(jsonPath("$.termInMonths").value(360))
    }

    @Test
    fun `POST simulate should return 400 for invalid request`() {
        // Negative principal, null rate, zero term - all invalid
        val invalidRequest = SimulationRequest(
            principal = BigDecimal("-100"),
            annualInterestRate = null,
            termInMonths = 0
        )

        mockMvc.perform(
            post("/api/v1/mortgages/simulate")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(invalidRequest))
        )
            .andExpect(status().isBadRequest)
            .andExpect(jsonPath("$.error").value("Validation Failed"))
    }

    @Test
    fun `GET by id should return 404 when not found`() {
        // Throw the not-found exception from the mocked service
        every { mortgageService.getSimulationById(999L) } throws SimulationNotFoundException(999L)

        mockMvc.perform(get("/api/v1/mortgages/999"))
            .andExpect(status().isNotFound)
            .andExpect(jsonPath("$.error").value("Not Found"))
    }
}

What do these controller tests verify?

  • The first test sends a valid POST request and checks it returns HTTP 201 with the expected JSON fields.
  • The second test sends invalid data (negative principal, null rate, zero term) and verifies the validation layer returns HTTP 400 with the Validation Failed error.
  • The third test confirms that requesting a non-existent ID triggers your GlobalExceptionHandler and returns HTTP 404.

The @MockkBean annotation replaces the real MortgageService in the Spring context with a mock. This means the controller test never touches the database or runs real calculations.

  • Save MortgageControllerTest.kt.
  • Stop your running application if it's still active (press Ctrl+C in the terminal where you ran spring-boot:run).
  • Run all tests by executing this command:
mvnw.cmd test

You should see Maven compile your project, run all 6 tests (3 service + 3 controller), and finish with BUILD SUCCESS.

What should I see?

The terminal output will include a T E S T S section showing each test class running. At the end you should see Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 followed by BUILD SUCCESS.

Seeing test failures?

  • If you see a compilation error, check that you saved pom.xml with the MockK dependencies and that your IDE resolved them.
  • If a test assertion fails on the monthly payment value, make sure your MortgageService.kt uses BigDecimal("1200") for the monthly rate conversion (not 12 or 100).
  • On macOS or Linux, use ./mvnw test instead of mvnw.cmd test.

Help me fix my failing tests

✔️ Awesome, I've got everything!

Great. Make sure you see all 6 tests passing in the output.

ⓧ I'd like to double check the full code

package com.itau.mortgagesimulator.controller

import com.fasterxml.jackson.databind.ObjectMapper
import com.itau.mortgagesimulator.dto.AmortizationEntry
import com.itau.mortgagesimulator.dto.SimulationRequest
import com.itau.mortgagesimulator.dto.SimulationResponse
import com.itau.mortgagesimulator.service.MortgageService
import com.itau.mortgagesimulator.service.SimulationNotFoundException
import com.ninjasquad.springmockk.MockkBean
import io.mockk.every
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.math.BigDecimal
import java.time.LocalDateTime

@WebMvcTest(MortgageController::class)
class MortgageControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @MockkBean
    private lateinit var mortgageService: MortgageService

    @Test
    fun `POST simulate should return 201 with simulation result`() {
        val request = SimulationRequest(
            principal = BigDecimal("200000"),
            annualInterestRate = BigDecimal("12"),
            termInMonths = 360
        )
        val response = SimulationResponse(
            id = 1,
            principal = BigDecimal("200000"),
            annualInterestRate = BigDecimal("12"),
            termInMonths = 360,
            monthlyPayment = BigDecimal("2057.23"),
            totalPaid = BigDecimal("740602.80"),
            totalInterest = BigDecimal("540602.80"),
            amortizationSchedule = listOf(
                AmortizationEntry(1, BigDecimal("2057.23"), BigDecimal("57.23"), BigDecimal("2000.00"), BigDecimal("199942.77"))
            ),
            createdAt = LocalDateTime.now()
        )

        every { mortgageService.simulate(any()) } returns response

        mockMvc.perform(
            post("/api/v1/mortgages/simulate")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
        )
            .andExpect(status().isCreated)
            .andExpect(jsonPath("$.monthlyPayment").value(2057.23))
            .andExpect(jsonPath("$.termInMonths").value(360))
    }

    @Test
    fun `POST simulate should return 400 for invalid request`() {
        val invalidRequest = SimulationRequest(
            principal = BigDecimal("-100"),
            annualInterestRate = null,
            termInMonths = 0
        )

        mockMvc.perform(
            post("/api/v1/mortgages/simulate")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(invalidRequest))
        )
            .andExpect(status().isBadRequest)
            .andExpect(jsonPath("$.error").value("Validation Failed"))
    }

    @Test
    fun `GET by id should return 404 when not found`() {
        every { mortgageService.getSimulationById(999L) } throws SimulationNotFoundException(999L)

        mockMvc.perform(get("/api/v1/mortgages/999"))
            .andExpect(status().isNotFound)
            .andExpect(jsonPath("$.error").value("Not Found"))
    }
}

Your API now has a full automated test suite covering both the business logic and the HTTP layer. Next up, you'll extend the project with a bonus feature in the Secret Mission.

Secret mission

Loan Comparison Endpoint

Your mortgage simulator already calculates payments for a single loan. But in real life, customers compare options. Can you build an endpoint that compares two loan configurations side-by-side and recommends the cheaper option?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally with an in-memory database, so there are no ongoing costs.

Resources you used:

  • The mortgage-simulator project folder on your Desktop.
  • The Spring Boot application process running on port 8080.
  • The H2 in-memory database (automatically destroyed when the process stops).

Keep everything running

No action needed. Choose this if you're still actively building or plan to continue with the next project in this series.

  • Your Spring Boot app keeps running on port 8080.
  • The H2 database retains simulation data as long as the process is alive.
  • Your project files remain on the Desktop ready for the next session.

Pause - I'll come back to this later

Stop the running process to free up memory and port 8080. Your project files stay on disk so you can restart later.

  • Switch to the terminal where Spring Boot is running.
  • Press Ctrl+C to stop the process.
  • Your project files remain in the mortgage-simulator folder on your Desktop.
  • To restart later, open a terminal in the project folder and run mvnw.cmd spring-boot:run (Windows) or ./mvnw spring-boot:run (macOS/Linux).

Delete - I don't want to use this again

Remove all project files and stop the running process. This frees up disk space and the port completely.

  • Switch to the terminal where Spring Boot is running and press Ctrl+C to stop the process.
  • Delete the project folder by running the appropriate command for your OS.

macOS

cd ~/Desktop
rm -rf mortgage-simulator

Windows

cd ~/Desktop
rmdir /s /q mortgage-simulator
  • The H2 in-memory database is automatically destroyed when the Spring Boot process stops. No separate cleanup is needed.

Nice Work!

Nice Work!

Nice work! You just rebuilt your Kotlin and Spring Boot foundations by creating a real mortgage simulator API.

You've learned how to:

  • Build a complete REST API in Kotlin with Spring Boot using the layered architecture pattern (Controller, Service, Repository) found in enterprise microservices.
  • Validate incoming requests with Jakarta Bean Validation annotations and handle errors globally with @RestControllerAdvice for structured JSON error responses.
  • Implement the Price/French amortization formula used in real Brazilian mortgage systems and write unit tests with JUnit 5 and MockK to verify the math.
  • Build a loan comparison endpoint that evaluates two mortgage configurations side-by-side and recommends the option with lower total interest.

Ready to quiz yourself?