Skip to content

13. Web Services

13.1. Introduction

In the previous chapter, we presented several TCP/IP client-server applications. Since the clients and the server exchange lines of text, they can be written in any language. The client simply needs to know the communication protocol expected by the server.

Web services are also server applications (TCP/IP). They have the following characteristics:

  • they are hosted by web servers, and the client-server exchange protocol is HTTP (HyperText Transfer Protocol), a protocol built on top of TCP/IP, which we explored in the previous chapter;
  • the web service has a standard communication protocol, regardless of the service provided. A web service offers various services: S1, S2…, Sn. Each of these services expects parameters provided by the client and returns a result to the client. For each service, the client needs to know:
    • how to specify the service Si;
    • the list of parameters to be provided and their types;
    • the type of result returned by the service. Once these elements are known, the client-server dialogue follows the same format, regardless of which web service is being queried. This standardizes client communication;
  • To protect against attacks from the Internet, many organizations open only certain ports on their servers to the outside world: primarily ports 80 (HTTP) and 443 (HTTPS) for the web service. All other ports are locked. Consequently, client-server applications such as those described in the previous chapter are generally restricted to the organization’s private network (intranet). Hosting a service on a web server makes it accessible to the entire Internet community;
  • the web service can be viewed as a remote object: the services it offers then become methods of that object. A client can use this remote object as if it were local, through an intermediary object called a proxy, which hides all network communication. This allows us to build a client that is independent of this layer: if the layer changes, the rest of the client does not need to be modified;
  • As with the TCP/IP client-server applications from the previous chapter, the client and server can be written in any programming language. They exchange lines of text consisting of two parts: the headers required by the HTTP protocol and the message body, whose format is agreed upon between the client and the server.

The architecture of a web service-based client-server application is as follows:

Image

This is an extension of the layered architecture from Chapter 6, to which specialized network communication layers are added: the client-side web service client and the web service itself on the server side. We have already encountered a similar architecture with the TCP tax calculation server (version 8) from the previous chapter.

13.1.1. Yesterday: SOAP and WSDL

In the 2000s, web services most often followed the SOAP standard (Simple Object Access Protocol). The client sent a HTTP request, whose body was a XML document: an “envelope” containing the name of the called method and its parameters. The server responded with another XML envelope containing the result. Each service was described by a XML document in WSDL format (Web Services Description Language), available at the service’s URL followed by [?wsdl]: it listed the methods, their parameters, and their types. Based on this document, tools automatically generated the service proxy.

In the JVM, these services were defined using the API and JAX-WS: an annotated class [@WebService], whose public methods (which might be annotated with [@WebMethod]) became the service’s methods; the [wsimport] tool generated the client proxy from the WSDL. JAX-WS was part of JDK from Java 6 through Java 10; it was removed in Java 11 (JEP 320) and now exists outside of JDK, under the name Jakarta XML Web Services. Kotlin, which was released (in version 1.0) in 2016, was not really around during that era: JAX-WS can be used from Kotlin just like any Java library, but new services rarely use it anymore. The reasons for this shift include the cumbersomeness of XML and SOAP envelopes, the complexity of the associated standards (WS-Security, WS-Addressing, etc.), and the difficulty of calling these services from a browser.

13.1.2. Today: REST and JSON

Most current web services follow the REST style (REpresentational State Transfer) and exchange JSON documents (JavaScript Object Notation, presented in Chapter 5). The REST style is based on the following principles:

  • the service exposes resources, each designated by a URL: [/tax], [/clients/12], [/clients/12/orders];
  • these resources are accessed using the methods (also known as verbs) of the HTTP protocol, each of which has a specific meaning;
  • the result of the action is indicated by the HTTP status code in the response;
  • the body of requests and responses is most often a JSON document, which is more compact and simpler than XML;
  • The service is stateless: each request contains all the information needed to process it; the server does not retain any information from one request to the next for a given client.

The most commonly used HTTP methods are as follows:

Method
Usage
Example
GET
Read a resource; any parameters are in the URL
GET /clients/12
POST
Create a resource or request processing; the data is in the request body
POST /clients
PUT
Replace a resource
PUT /clients/12
PATCH
Modify part of a resource
PATCH /clients/12
DELETE
Delete a resource
DELETE /clients/12

The status codes, which were introduced in the previous chapter, take on their full meaning here:

Code
Meaning
200 OK
The request was successful; the response body contains the result
201 Created
The resource was created; the [Location] header provides its URL
204 No Content
The request was successful; the response has no body
400 Bad Request
The request is invalid (missing or invalid parameter, etc.)
404 Not Found
The requested resource does not exist
415 Unsupported Media Type
The type of the document sent (header [Content-Type]) is not accepted
500 Internal Server Error
An unexpected error occurred on the server

A REST service does not have a mandatory equivalent in WSDL. However, it is common practice to describe it using a OpenAPI document (formerly Swagger), in JSON or YAML format, from which tools can generate documentation, test pages, and clients. We’ll come back to this later.

Finally, note gRPC, designed by Google: the service’s methods and their messages are described in a [.proto] file, from which tools generate the server and client, just as they did in the days of WSDL; the messages are encoded in a compact binary format (Protocol Buffers) and transmitted via HTTP/2. gRPC is highly efficient and popular for communication between services within the same organization, but it is ill-suited for browsers. It is available on JVM with the [grpc-java] library, supplemented for Kotlin by [grpc-kotlin] (the functions in [suspend] and [Flow], Chapter 9), which we will not be using.

13.1.3. Spring Boot and Spring MVC

To write our web services, we will use Spring Boot. In Chapter 6, we used the Spring Framework’s dependency injection container. This framework also includes a web module, Spring MVC, which associates a URL and a HTTP method with a function using annotations: [@RestController] on the class, [@GetMapping], [@PostMapping]… on its methods. Spring Boot (version 1.0 in 2014, version 4 in November 2025, with Spring Framework 7) simplifies the implementation of Spring:

  • it automatically configures the application based on the available libraries: if Spring MVC and Jackson are present, it sets up everything needed for a web service JSON, without a single line of configuration;
  • it embeds the web server within the application: the Tomcat server (version 11) is a simple library, launched by the main program. Therefore, there is no need to install a separate web server or application server (Tomcat, GlassFish, WildFly…), on which the application used to be deployed as a [.war] archive;
  • it provides starters—dependencies that bundle everything needed for a specific use case. The [spring-boot-starter-webmvc] starter includes Spring MVC, Tomcat, Jackson 3 (Chapter 5), and logging (SLF4J and Logback);
  • It provides a bill of materials (BOM), which specifies the versions of all these libraries—which are compatible with one another—and a Gradle plugin, [org.springframework.boot], which launches the application ([gradlew bootRun]) and packages it as a single executable file ([.jar]) ([gradlew bootJar]).

Spring is written in Java, but it has treated Kotlin as a first-class language since Spring Framework 5 (2017): its documentation provides examples in both languages, it supports Kotlin’s nullable types, can call primary constructors and their default values, and provides Kotlin-specific extension functions ([runApplication], [getBean<T>()], [registerBean<T>()] from Chapter 6…). Spring Boot isn’t the only solution: Jakarta EE (formerly Java EE) defines the API standard JAX-RS (Jakarta RESTful Web Services), and frameworks such as Quarkus, Micronaut, and Helidon are also widely used with Kotlin. Of particular note is Ktor, the JetBrains web framework written in Kotlin for Kotlin: its routes are described using a dedicated language (DSL) rather than annotations, and its handlers are [suspend] functions (Chapter 9). Spring Boot, however, is the most widely used, and it naturally builds on what we covered in Chapter 6.

13.2. A First Web Service

We’re going to build our first client-server application with the following simplified architecture:

Image

The projects in this chapter form a Gradle multi-project build (Chapter 6), the [13-web-services] folder:

13-web-services/
  settings.gradle.kts       the build: the six subprojects and version 5 (chapter 6)
  build.gradle.kts          the root project: the plugins and their versions
  gradlew, gradlew.bat, gradle/     the Gradle wrapper
  WsHello/                  "Hello" web service
    build.gradle.kts
    src/main/kotlin/wshello/WsHelloApplication.kt
    src/main/kotlin/wshello/HelloController.kt
    src/main/resources/application.properties
  ClientWsHello/            its console client
    build.gradle.kts
    src/main/kotlin/clientwshello/Main.kt
  WsOperations/             web service of the four operations
    build.gradle.kts
    src/main/kotlin/wsoperations/WsOperationsApplication.kt
    src/main/kotlin/wsoperations/OperationsController.kt
    src/main/resources/application.properties
  ClientWsOperations/       its console client
    build.gradle.kts
    src/main/kotlin/clientwsoperations/Main.kt
  WsTax/                    tax calculation web service (version 9)
    build.gradle.kts
    TaxData.json
    src/main/kotlin/tax/ws/...
    src/main/resources/application.properties
    src/main/resources/static/index.html    the web client
  ClientWsTax/              its console client (version 9)
    build.gradle.kts
    src/main/kotlin/tax/client/TaxBusinessHttp.kt
    src/main/kotlin/tax/client/Main.kt
  ClientNode/               a JavaScript client
    client-tax.mjs

The file [settings.gradle.kts] lists the subprojects:

// chapter 13: a Gradle build that groups the six projects of the chapter

// Gradle downloads the JDK requested by jvmToolchain(25) itself if it does not find it on the machine
plugins {
    id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}

rootProject.name = "web-services"

// the web services (Spring Boot applications) and their console clients
include("WsHello", "ClientWsHello", "WsOperations", "ClientWsOperations", "WsTax", "ClientWsTax")

// the layers of version 5 (chapter 6) are reused by WsTax and ClientWsTax: composite build
includeBuild("../06-architectures")
  • lines 3–6: the plugin that allows Gradle to download JDK 25 itself if it cannot find it on the machine (Chapter 2);
  • line 11: the six subprojects, identified by their folder names;
  • line 14: the build for Chapter 6 is included in this one (composite build, already used in Chapters 10 and 11): the projects [WsTax] and [ClientWsTax] may depend on the modules from version 5 of the tax application [TaxV5] via their coordinates ([examples.tax:tax-business:5.0]), and Gradle will build these modules from their source code if necessary.

The root project declares the plugins used by the subprojects, along with their versions, without applying them:

1
2
3
4
5
6
7
// chapter 13: root project. The plugins are declared here, with their version, without being applied:
// it is the subprojects that apply them
plugins {
    kotlin("jvm") version "2.4.20" apply false
    kotlin("plugin.spring") version "2.4.20" apply false
    id("org.springframework.boot") version "4.1.1" apply false
}
  • line 4: the Kotlin plugin, which we are familiar with;
  • line 5: the [all-open] plugin for Spring, used in Chapter 6. In Kotlin, classes and their functions are final by default; however, Spring needs to derive subclasses (proxies) from them for some of its classes. This plugin makes the classes annotated by Spring ([@Configuration], [@Component], [@RestController], [@SpringBootApplication]…) and their methods;
  • Line 6: the Spring Boot plugin.

The projects are already set up in the course code: the reader simply needs to open the [13-web-services] folder within IntelliJ IDEA, which recognizes it as a Gradle build, or enter the specified Gradle command in that folder. Services are launched using [gradlew :WsHello:bootRun] (the path [:WsHello] refers to the subproject), and console clients using [gradlew -q :ClientWsHello:run]. The command [gradlew build] builds all projects at once.

To create a Spring Boot project yourself, the easiest way is to use the Spring Initializr website: there, you select “Gradle - Kotlin” (a Gradle project whose build files are written in Kotlin), the Kotlin language, the Spring Boot version, the project name, and its dependencies (in this case, “Spring Web”), and then download a ZIP file containing a ready-to-use project: the files [build.gradle.kts] and [settings.gradle.kts], the Gradle wrapper, the main class, and the file [application.properties]. IntelliJ IDEA (Ultimate edition) offers the same wizard. For a Kotlin project, Spring Initializr automatically adds the dependencies [kotlin-reflect] and [jackson-module-kotlin], which we’ll discuss.

13.2.1. The server-side

Image

The [build.gradle.kts] file in the [WsHello] project is as follows:

// the WsHello web service: a Spring Boot application
plugins {
    kotlin("jvm")
    // makes the classes annotated by Spring open (non-final) (@SpringBootApplication, @Configuration…)
    kotlin("plugin.spring")
    // gradlew bootRun starts the application; gradlew bootJar makes an executable jar of it
    id("org.springframework.boot")
}

group = "examples.webservices"
version = "1.0"

repositories {
    mavenCentral()
}

dependencies {
    // the Spring Boot bill of materials (BOM): it sets the versions of the libraries below
    implementation(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES))
    // Spring MVC, the embedded Tomcat server and Jackson
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    // Kotlin reflection: Spring uses it for the classes and functions written in Kotlin
    implementation("org.jetbrains.kotlin:kotlin-reflect")
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        // the parameter names are kept: Spring and Jackson use them
        javaParameters = true
    }
}

// on Windows, the screen output of the program launched by Gradle (run, bootRun...) is encoded in UTF-8,
// otherwise accented characters are garbled in the IntelliJ IDEA console or the terminal
tasks.withType<JavaExec>().configureEach {
    jvmArgs("-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
}

Comments:

  • Lines 3–7: The three plugins, without a version specified—the version is determined by the root project. The Spring Boot plugin adds the task [bootRun] to the project, which compiles and runs the application, and the task [bootJar], which creates a file named [build/libs/WsHello-1.0.jar] containing the application and all its dependencies, which is then launched by [java -jar build/libs/WsHello-1.0.jar]. There is no need to apply the [application] plugin: the main class is found by the Spring Boot plugin, which looks for the [main] function;
  • Line 19: the Spring Boot 4.1.1 version (the plugin version). The [platform] function imports into the project the versions of several hundred libraries, selected and tested together by the Spring Boot team. This is why the dependencies on lines 21–23 do not have versions. This is the equivalent of the parent project [spring-boot-starter-parent] in a Maven project;
  • line 21: the web starter. It provides Spring MVC (Spring Framework 7.0), the embedded Tomcat 11 server, Jackson 3, and logging;
  • line 23: the Kotlin reflection library. Java reflection ([java.lang.reflect]) sees only part of what the Kotlin code expresses: it does not know whether a parameter is nullable ([String?]), whether it has a default value, or what the primary constructor of a class is. Spring reads this information using [kotlin-reflect] (which decodes the metadata that the Kotlin compiler stores in each [.class] file): we’ll see that it uses this for optional parameters. The version used is that of the Kotlin plugin;
  • line 31: the compiler’s [-java-parameters] option, which stores the names of function parameters in the [.class] files. Spring needs this to map a function parameter ([name]) to the parameter of the same name in a URL, and Jackson needs it to map the constructor parameters of a data class to the properties of a JSON document (Chapter 5). The Spring Boot plugin already enables this option when it detects the Kotlin plugin; we’re including it here to make it explicit;
  • lines 35–38: Programs launched by Gradle write their output to UTF-8 so that accented characters display correctly on Windows (Chapter 2).

The main class is as follows:

package wshello

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

// the WsHello Spring Boot application: an empty class, which only serves to carry the annotation
@SpringBootApplication
class WsHelloApplication

fun main(args: Array<String>) {
    // starts Spring, then the Tomcat server that waits for the requests
    runApplication<WsHelloApplication>(*args)
}
  • line 7: the [@SpringBootApplication] annotation combines three annotations: [@Configuration] (the class can define beans, as in Chapter 6), [@EnableAutoConfiguration] (automatic configuration based on the available libraries), and [@ComponentScan] (Spring searches for annotated classes—controllers, configurations, etc.—in this class’s package and its subpackages);
  • Line 8: The class has no body; it serves only to carry the annotation. Since it is (indirectly) annotated with [@Configuration], the [plugin.spring] plugin makes it non-final. Here, this is not essential: we have verified that the application also starts when the class remains final, since Spring does not need to derive a configuration class that does not define any beans. However, a configuration class that defines beans using [@Bean] functions—such as the tax calculation service—would cause the [@Configuration class '...' may not be final] error encountered in Chapter 6: the plugin takes care of this for you;
  • lines 10–12: the main program is a high-level function, compiled into the [WsHelloApplicationKt] class. The function [runApplication<WsHelloApplication>(*args)], a Spring Boot extension function written in Kotlin, replaces the Java call [SpringApplication.run(WsHelloApplication.class, args)]: the type of the configuration class is passed as an argument of type ([reified], Chapter 4), and the [*] (spread) operator passes the elements of the [args] array to the [vararg] parameter of the function (Chapter 3). [runApplication] reads the configuration ([application.properties] file, environment variables, command-line arguments), creates the Spring container and its beans, starts the Tomcat server, and then returns control. However, the program does not stop: the Tomcat threads wait for requests until the application is terminated (Ctrl-C).

The application configuration is stored in the file [src/main/resources/application.properties]:

1
2
3
4
5
6
7
8
# the name of the application
spring.application.name=WsHello
# the listening port of the Tomcat server
server.port=5100
# no banner at startup
spring.main.banner-mode=off
# shorter log messages: time, level, thread, class, message
logging.pattern.console=%d{HH:mm:ss.SSS} %-5level [%thread] %logger{0} : %msg%n
  • line 2: the application name;
  • line 4: the server listens on port 5100 (8080 by default). This value could also be passed as an argument: [gradlew :WsHello:bootRun --args=--server.port=5100], or via the environment variable [SERVER_PORT];
  • line 6: By default, Spring Boot displays a large banner with the text ASCII at startup; we remove it;
  • line 8: the format of log messages. The default format is very comprehensive (date, level, process ID, application name, thread, full class name, message) but results in very long lines. We’ll reduce it to the time, the message level, the thread name, the short name of the issuing class ([%logger{0}]), and the message.

The web service itself is the class [HelloController]. It provides a function that returns the string [Hello Mrs xx], where xx is the received name:

package wshello

import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController

// the WsHello web service: each annotated function processes a URL
@RestController
class HelloController {

    // the root of the site: a welcome text
    @GetMapping("/")
    fun home() = "WsHello web service: try /hello/{name} or /greeting/{name}"

    // a "web method" that has become a URL: it says hello to the lady
    // the {name} segment of the URL is passed to the [name] parameter of the function
    // a string is returned as it is (text/plain)
    @GetMapping("/hello/{name}")
    fun hello(@PathVariable name: String) = "Hello Mrs $name"

    // the same thing with a JSON response: an object is serialized into JSON (application/json)
    @GetMapping("/greeting/{name}")
    fun greeting(@PathVariable name: String) = Greeting(name, "Hello Mrs $name")

    // the result of the /greeting function
    data class Greeting(val name: String, val message: String)
}

Comments:

  • Line 8: The annotation [@RestController] designates the class as a controller: Spring creates a single instance (a singleton) of it at startup and routes requests of the form HTTP to it, where URL corresponds to one of its functions. The result of each method is sent as-is to the client, in the response body;
  • lines 12–13: The annotation [@GetMapping("/")] associates the method HTTP GET with the URL [/] (the site root) with the function [home], which returns text explaining how to use the service. The function has an expression body ([= ...]): its type, [String], is inferred. The curly braces in [{name}] are nothing out of the ordinary in a Kotlin string: only the character [$] introduces a template;
  • Lines 18–19: The template for URL (known as the path) [/hello/{name}] contains a variable [{name}]: it corresponds to any segment of URL placed after [/hello/]. Thus, URL [/hello/Carla%20Bruni] assigns the value [Carla Bruni] to [{name}] (the code [%20] represents a space in URL). The annotation [@PathVariable] instructs Spring to pass this value to the function’s parameter of the same name: this is data binding. The function returns a string, constructed using a template: this string is sent as-is to the client as a text document (type [text/plain]);
  • lines 22–23: the same process, but the function returns an object of type [Greeting]. Spring serializes it to JSON using Jackson (Chapter 5) and sends it to the client as a document of type [application/json]. This is the standard format for a REST web service response;
  • line 26: the result type, a data class (Chapter 4) nested within the controller (the equivalent of a Java record). Jackson serializes it using its accessors [getName()] and [getMessage()], generated by the compiler: each property becomes a JSON property with the same name.

The service is launched by the command [gradlew :WsHello:bootRun], entered in the [13-web-services] folder (or [..\gradlew bootRun] entered in the [WsHello] folder: the wrapper is at the root of the project). The application’s execution directory is that of the subproject. It displays its log (the course has abbreviated the paths in line 2 to [...] and omitted the Gradle messages):

> gradlew :WsHello:bootRun
12:51:32.995 INFO  [main] WsHelloApplicationKt : Starting WsHelloApplicationKt using Java 25.0.4 with PID 9407 (... in .../13-web-services/WsHello)
12:51:33.082 INFO  [main] WsHelloApplicationKt : No active profile set, falling back to 1 default profile: "default"
12:51:41.136 INFO  [main] TomcatWebServer : Tomcat initialized with port 5100 (http)
12:51:41.282 INFO  [main] StandardService : Starting service [Tomcat]
12:51:41.282 INFO  [main] StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
12:51:41.772 INFO  [main] WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 7597 ms
12:51:46.628 INFO  [main] TomcatWebServer : Tomcat started on port 5100 (http) with context path '/'
12:51:46.655 INFO  [main] WsHelloApplicationKt : Started WsHelloApplicationKt in 16.514 seconds (process running for 18.473)
12:51:50.735 INFO  [http-nio-5100-exec-1] [/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
12:51:50.737 INFO  [http-nio-5100-exec-1] DispatcherServlet : Initializing Servlet 'dispatcherServlet'
12:51:50.744 INFO  [http-nio-5100-exec-1] DispatcherServlet : Completed initialization in 6 ms
12:54:16.917 INFO  [SpringApplicationShutdownHook] GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
12:54:16.933 INFO  [tomcat-shutdown] GracefulShutdown : Graceful shutdown complete
  • Line 2: the application startup, the Java version, and the runtime directory, which is the project directory. Note the name [WsHelloApplicationKt]: Spring Boot names the application after the class containing the [main] function—in this case, the class generated by the Kotlin compiler for the [WsHelloApplication.kt] file, not the [WsHelloApplication] class;
  • line 3: Spring allows you to define profiles (development, test, production, etc.) that enable different configurations ([application-dev.properties] files, etc.). No profile is active here;
  • Lines 4–6: The embedded Tomcat server is created; it will listen on port 5100;
  • line 7: The Spring container is ready: it has created its beans, including our controller;
  • lines 8–9: Tomcat is now accepting connections; the application is ready. Startup took about fifteen seconds on our test machine, which is rather slow (and busy with other tasks); it usually takes two or three seconds;
  • lines 10–12: these messages appeared during the first request: the Spring servlet [DispatcherServlet], which receives all requests and distributes them among the controller methods, is only initialized at this point. A servlet is a Java component that processes requests in a web server such as Tomcat (Jakarta Servlet); Spring is built on top of this. The thread name, [http-nio-5100-exec-1], indicates that the request is being processed by one of the Tomcat server’s threads;
  • lines 13–14: these messages appeared when the server was shut down using Ctrl-C, after the tests described in the following paragraphs: the shutdown is “graceful”; Tomcat completes any pending requests before shutting down.

Each log message includes its time, level ([TRACE], [DEBUG], [INFO], [WARN], [ERROR]), the thread that sent it, and the sender class. Messages at level [INFO] and above are displayed by default; the [logging.level.<package>=DEBUG] property also displays lower-level messages for a given package.

13.2.2. Querying the service with curl

While the service is running, let’s query it in a second command window using the [curl] tool introduced in the previous chapter. The [-i] option displays the response headers before the body, and the [-s] (silent) option suppresses the progress bar:

1
2
3
4
5
6
7
> curl -s -i http://localhost:5100/hello/Carla%20Bruni
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 21
Date: Sun, 04 Oct 2026 10:51:52 GMT

Hello Mrs Carla Bruni
  • Line 2: The request was successful. Tomcat does not send the text that usually follows the status code ([OK]): it has been optional since HTTP/1.1 (and no longer exists in HTTP/2); only the code matters;
  • Line 3: The document is text (UTF-8);
  • line 4: the document size, in bytes;
  • line 5: the response time, always expressed in Coordinated Universal Time (GMT) in the HTTP headers, whereas the server log uses local time;
  • line 7: the result of the function [hello].

Now let’s request the JSON version:

1
2
3
4
5
6
7
> curl -s -i http://localhost:5100/greeting/Carla%20Bruni
HTTP/1.1 200
Content-Type: application/json
Content-Length: 56
Date: Sun, 04 Oct 2026 10:51:52 GMT

{"name":"Carla Bruni","message":"Hello Mrs Carla Bruni"}
  • line 3: the document is now of type [application/json]. It is not necessary to specify the character set: a JSON document exchanged over the network is always encoded as UTF-8 (RFC 8259);
  • Line 7: the serialized [Greeting] object. The properties JSON have the same names as the properties of the data class, [name] and [message], and are listed in the order of its primary constructor. This was not obvious: by default, Jackson 3 sorts the properties of an ordinary class in alphabetical order (we’ll see this with the [ProblemDetail] class), but it first lists, in their order, those that are parameters of the constructor it chose to create the objects. Thanks to the [javaParameters] option, it knows the names of these parameters and recognizes the primary constructor of the data class. Since the Kotlin names are already in “camelCase,” “JavaScript,” and “JSON,” there is nothing to convert.

A URL that does not match any route returns a 404 response:

1
2
3
4
5
6
7
> curl -s -i http://localhost:5100/hi
HTTP/1.1 404
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 04 Oct 2026 10:51:53 GMT

{"timestamp":"2026-10-04T10:51:52.930Z","status":404,"error":"Not Found","path":"/hi"}
  • Line 4: The document is sent in chunks, each preceded by its size; the server did not have to calculate the total size of the document before starting to send it;
  • line 7: the error document generated by Spring Boot: the date, the status code, its text, and the requested path. For security reasons, it contains neither the message of any exception nor the call stack (properties [server.error.include-message] and [server.error.include-stacktrace], disabled by default). We’ll see in the next service how to obtain a standardized error document.

You can also type URL [http://localhost:5100/hello/Carla Bruni] into a browser’s address bar, which will display the received text: a web service queried by GET can be tested very easily.

13.2.3. A console client

Image

It is possible to write the service client using a simple TCP client, as in the previous chapter: simply send the request HTTP and read the response. However, we use the [java.net.http.HttpClient] class from JDK (Java 11), which was also introduced in the previous chapter; it handles the HTTP protocol for us and uses Jackson for the JSON documents. The [ClientWsHello] project is a standard Kotlin project, whose [build.gradle.kts] file declares the Jackson dependency and the main class:

dependencies {
    // Jackson to read the JSON responses
    implementation("tools.jackson.core:jackson-databind:3.1.5")
}

kotlin {
    jvmToolchain(25)
    compilerOptions {
        allWarningsAsErrors = true
        // the parameter names are kept: Jackson uses them for the data classes
        javaParameters = true
    }
}

application {
    // gradlew -q :ClientWsHello:run [--args="service url"]
    mainClass = providers.gradleProperty("main").orElse("clientwshello.MainKt")
}

tasks.named<JavaExec>("run") {
    // the program being run can read the keyboard
    standardInput = System.`in`
}
  • line 3: the Jackson 3 dependency; it has a version because this project does not use the Spring Boot naming convention;
  • line 11: Jackson needs the constructor parameter names to create a [Greeting] object from a JSON document (Chapter 5);
  • Lines 15–17: the main class, executed by [gradlew -q :ClientWsHello:run];
  • Lines 20–22: The program reads from the keyboard.

The program is as follows:

package clientwshello

import java.io.IOException
import java.net.ConnectException
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import tools.jackson.databind.json.JsonMapper

// the JSON response of the service: same property names as on the server side
data class Greeting(val name: String, val message: String)

// console client of the WsHello web service
fun main(args: Array<String>) {
    // the URL of the service can be passed as an argument
    val serviceUrl = URI.create(args.firstOrNull() ?: "http://localhost:5100/")
    // Jackson's JSON <-> objects converter
    val mapper = JsonMapper.builder().build()

    // a single HTTP client for the whole duration of the program, closed at the end by [use]
    HttpClient.newHttpClient().use { client ->
        while (true) {
            // the name of the lady
            print("Name of the lady (nothing to stop): ")
            val name = readlnOrNull()?.trim() ?: ""
            if (name.isEmpty()) {
                break
            }
            try {
                // the name is placed in the path of the URL: it must be encoded there
                val encodedName = URLEncoder.encode(name, Charsets.UTF_8).replace("+", "%20")
                // 1 - text response
                val text = client.get(serviceUrl.resolve("hello/$encodedName"))
                println("[text] $text")
                // 2 - JSON response, deserialized into a Greeting object
                val json = client.get(serviceUrl.resolve("greeting/$encodedName"))
                val greeting = mapper.readValue(json, Greeting::class.java)
                println("[JSON] $greeting")
            } catch (_: ConnectException) {
                // no service is listening at this address (the exception has no message)
                println("Error: ConnectException: connection refused ($serviceUrl)")
            } catch (e: IOException) {
                // the service responded with an error, or the communication failed
                println("Error: ${e::class.simpleName} : ${e.message}")
            }
        }
    }
}

// a GET request whose response body is returned as a string
// (an extension function of the HttpClient class)
fun HttpClient.get(url: URI): String {
    val request = HttpRequest.newBuilder(url).GET().build()
    val response = send(request, HttpResponse.BodyHandlers.ofString())
    // a status code other than 2xx is an error
    if (response.statusCode() / 100 != 2) {
        throw IOException("the service responded ${response.statusCode()} for $url")
    }
    return response.body()
}

Comments:

  • line 13: the service’s response, JSON, will be deserialized into a [Greeting] object. The client does not have access to the server’s [Greeting] type: it declares its own, a data class with the same property names. A client does not share the server’s code, only the format of the exchanged documents;
  • Line 18: The service's URL is passed as an argument; otherwise, it defaults to [http://localhost:5100/]: if the service's address changes, the client does not need to be recompiled. [firstOrNull()] returns the first element of the array, or [null] if it is empty, and the Elvis operator [?:] replaces this [null] with the default URL;
  • line 20: the JSON converter from Jackson 3 (Chapter 5);
  • line 23: a single [HttpClient] for the entire program. It manages a set of reusable connections and must be shared, not recreated for each request. Starting with Java 21, [HttpClient] implements [AutoCloseable]: the Kotlin function [use] (Chapter 3) closes it at the end of the block, even if an exception occurs;
  • lines 26–27: the question, followed by reading the answer; at the end of the stream, [readlnOrNull] returns [null], which the Elvis operator converts to an empty string;
  • line 33: the entered name becomes a segment of URL: characters not allowed there must be encoded. [URLEncoder.encode] encodes the “é” as [%C3%A9], but it is designed for form parameters, where a space is encoded as [+]. In a URL path, the space must be encoded as [%20]: so we replace the [+] (a valid [+] entered is encoded as [%2B] and is not affected). [Charsets.UTF_8] is the Kotlin constant for the character set UTF-8 (the Java constant, [StandardCharsets.UTF_8], also works);
  • line 35: the [resolve] method of the [URI] class constructs a URL from the base URL and a relative URL. Note: The base URL must end with [/] for [hello/...] to be added to it. The [get] function is called as a method of [HttpClient]: it is an extension function, defined below;
  • lines 38–39: The response JSON is deserialized into a [Greeting] object using Jackson’s [readValue] method;
  • lines 41–43: The [ConnectException] exception is thrown when a connection to the service is impossible, for example, when no service is listening at the specified address. It has no message; we construct one. The name [_] indicates that the exception is not used. This [catch] must precede the one on line 44, because [ConnectException] is derived from [IOException];
  • lines 44–46: other communication errors and error responses from the service (see line 59);
  • lines 54–61: a GET request, whose response body is rendered as a string. We wrote it as an extension function of the [HttpClient] class (Chapter 4): within its body, [send] refers to the [send] method of the client on which it is called (*[this]*). In Java, this was a static method that took the client as a parameter;
  • line 55: the request is built using a builder: URL, the HTTP method, and then [build];
  • Line 56: The [send] method sends the request and waits for the response. Its second parameter, a [BodyHandler], specifies what to do with the response body: [ofString] reads it entirely into a string, decoded according to the character set specified by the [Content-Type] header (UTF-8 by default). [send] calls [IOException] in the event of a communication error and [InterruptedException] if the thread is interrupted while waiting. Since Kotlin does not have checked exceptions, neither the [get] function nor the [main] function need to declare them ([throws IOException, InterruptedException] in Java);
  • Lines 58–59: Unlike other libraries, [HttpClient] does not treat an error status code (4xx, 5xx) as an exception: the response is returned normally, and it is up to us to examine its status code. Here, we trigger a [IOException] for any code other than 2xx.

Results, with the service running (the input data, provided to the program via a redirect, was reinserted after the queries):

1
2
3
4
5
6
7
8
> gradlew -q :ClientWsHello:run
Name of the lady (nothing to stop): Carla Bruni
[text] Hello Mrs Carla Bruni
[JSON] Greeting(name=Carla Bruni, message=Hello Mrs Carla Bruni)
Name of the lady (nothing to stop): Marie Curie
[text] Hello Mrs Marie Curie
[JSON] Greeting(name=Marie Curie, message=Hello Mrs Marie Curie)
Name of the lady (nothing to stop):

Line 4 displays the received [Greeting] object, in the format of the [toString] function generated for the data classes: [Greeting(name=…, message=…)], where a Java record previously displayed [Greeting[name=…, message=…]]. The Gradle option [-q] (quiet) suppresses its own messages. If you specify an address where no service is listening, the connection is refused:

1
2
3
4
> gradlew -q :ClientWsHello:run --args="http://localhost:5199/"
Name of the lady (nothing to stop): Carla Bruni
Error: ConnectException: connection refused (http://localhost:5199/)
Name of the lady (nothing to stop):

And if the service’s URL is incorrect, the service returns a 404:

1
2
3
4
> gradlew -q :ClientWsHello:run --args="http://localhost:5100/hi/"
Name of the lady (nothing to stop): Carla Bruni
Error: IOException : the service responded 404 for http://localhost:5100/hi/hello/Carla%20Bruni
Name of the lady (nothing to stop):

The client’s [Greeting] class has two non-nullable properties of type [String]. What would happen if the service omitted one of them—[message], for example? Jackson would call the constructor with the value [null] for the parameter [message], and it is the code generated by the Kotlin compiler—which checks at the entry point of every public function that its non-nullable parameters are not [null]—that would reject this value. We verified this with a small test program:

tools.jackson.databind.exc.ValueInstantiationException: Cannot construct instance of `Greeting`, problem: Parameter specified as non-null is null: method Greeting.<init>, parameter message

Safety with respect to [null] is therefore preserved, even for objects created via reflection. A missing property of type [Int], on the other hand, is rejected by Jackson 3 itself (***[Cannot mapnullinto typeint]***). To allow a property to be missing, it is declared as nullable ([String?], [Int?]); this is what we will do in the following services. Jackson’s Kotlin module, [jackson-module-kotlin] (dependency [tools.jackson.module:jackson-module-kotlin]), goes a step further: it includes default values for parameters (a missing property takes the default value) and provides more specific error messages. It also provides the extension function [mapper.readValue<Greeting>(json)], which replaces [mapper.readValue(json, Greeting::class.java)]. When it is included among the dependencies of a Spring Boot application, the application automatically registers it with Spring’s JSON converter (MVC); Spring Initializr also adds it to Kotlin projects. We won’t be using it in this course, as the data classes and the [javaParameters] option are sufficient for our needs.

With a SOAP service, the client used a proxy generated from the service’s WSDL and called the remote method as if it were a local method. Here, we call the service’s URL directly. We will write a true proxy for the tax calculation service.

13.2.4. Service description: OpenAPI

The WSDL described a SOAP service in a standardized format. Its current equivalent is the OpenAPI document. For Spring Boot, the [springdoc-openapi] library ([org.springdoc:springdoc-openapi-starter-webmvc-ui] dependency) generates it without a single line of code: it scans the controllers and their annotations at startup and publishes the document to URL and [/v3/api-docs], as well as an interactive test page, Swagger UI, to URL and [/swagger-ui.html]. The document describes each route: its URL, its method HTTP, its parameters and their types, and the schema JSON for its responses. With Kotlin, it takes into account the nullability of types: a property of type [String] is declared required, while a property of type [String?] is optional. Generators such as OpenAPI Generator use this to generate a typed client in various languages (including Kotlin), just as [wsimport] did with WSDL. This library is not part of Spring Boot, and we will not be using it in this chapter.

13.3. A Web Service for Arithmetic Operations

The previous service offered a single function. We will now consider a web service that provides the four arithmetic operations: add(a, b), subtract(a, b), multiply(a, b), and divide(a, b). We will use this opportunity to explore the two ways to pass parameters to a web service and how to report errors.

13.3.1. The Server Side

The [WsOperations] service accepts two types of requests:

Request
Parameters
GET /add?a=1.5&b=2
The operation in the URL path, the numbers in its query string
POST /calculation
a document JSON in the request body: [{"operation":"add","a":1.5,"b":2}]

In both cases, the response is a document JSON: [{"operation":"add","a":1.5,"b":2.0,"result":3.5}]. GET is suitable for simple requests that do not modify anything on the server: URL can be typed into a browser, bookmarked, and its response can be cached. POST is suitable for large or structured data. The project’s [build.gradle.kts] file is the same as [WsHello], except for the comment, and its [WsOperationsApplication.kt] file is identical to [WsHello], except for the names ([WsOperationsApplication], package [wsoperations]). Its [application.properties] file is as follows:

# the name of the application
spring.application.name=WsOperations
# the listening port of the Tomcat server
server.port=5110
# the errors detected by Spring MVC are reported in the "Problem Details" format (RFC 9457)
spring.mvc.problemdetails.enabled=true
# no banner at startup
spring.main.banner-mode=off
# shorter log messages: time, level, thread, class, message
logging.pattern.console=%d{HH:mm:ss.SSS} %-5level [%thread] %logger{0} : %msg%n
  • line 4: the service is listening on port 5110;
  • line 6: errors detected by Spring MVC itself (unknown URL, invalid JSON document, unsupported document type, etc.) will be reported via a response in the Problem Details format. This format, standardized by RFC 9457, is a JSON document of type [application/problem+json] that describes an error: its type, a title, the status code, a detail, and any additional information. This is the standard way for a REST service to indicate what is wrong.

The controller is as follows:

package wsoperations

import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
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.RequestParam
import org.springframework.web.bind.annotation.RestController

// the WsOperations web service: the four arithmetic operations
@RestController
class OperationsController {

    // GET /add?a=1.5&b=2 (likewise for subtract, multiply, divide)
    // [operation] comes from the path of the URL, [a] and [b] from its query string
    // a and b are of type String?: Spring therefore considers them optional
    @GetMapping("/{operation}")
    fun calculateGet(
        @PathVariable operation: String,
        @RequestParam a: String?,
        @RequestParam b: String?,
    ): ResponseEntity<*> = calculate(operation, readNumber(a), readNumber(b))

    // POST /calculation with a JSON body: {"operation":"add","a":1.5,"b":2}
    // the JSON body is deserialized into the [request] parameter
    @PostMapping("/calculation")
    fun calculatePost(@RequestBody request: CalculationRequest): ResponseEntity<*> =
        calculate(request.operation, request.a, request.b)

    // a number written with a decimal point, null if it is missing or incorrect
    // (NaN and infinities are refused)
    private fun readNumber(text: String?): Double? = text?.toDoubleOrNull()?.takeIf { it.isFinite() }

    // the calculation common to the two HTTP methods
    private fun calculate(operation: String?, a: Double?, b: Double?): ResponseEntity<*> {
        // the name of the operation, in lower case
        val name = operation?.lowercase() ?: ""
        // the requested operation, null if it is unknown
        val calculation: ((Double, Double) -> Double)? = when (name) {
            "add" -> { x, y -> x + y }
            "subtract" -> { x, y -> x - y }
            "multiply" -> { x, y -> x * y }
            "divide" -> { x, y -> x / y }
            else -> null
        }
        // checking the parameters: the errors are accumulated
        if (calculation == null || a == null || b == null) {
            val errors = buildMap {
                if (calculation == null) {
                    put("operation", listOf(
                        "Unknown operation [$name]: use add, subtract, multiply or divide"))
                }
                if (a == null) put("a", listOf("The number a is missing or incorrect"))
                if (b == null) put("b", listOf("The number b is missing or incorrect"))
            }
            // 400 response with the list of errors
            val problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST).apply {
                title = "Incorrect parameters"
                setProperty("errors", errors)
            }
            return ResponseEntity.badRequest().body(problem)
        }
        // here, the compiler knows that calculation, a and b are not null (smart cast)
        // division by zero is refused: 400 response
        if (name == "divide" && b == 0.0) {
            val problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, "The number b must be non-zero for a division"
            ).apply { title = "Division by zero" }
            return ResponseEntity.badRequest().body(problem)
        }
        // 200 response with the result in JSON
        return ResponseEntity.ok(CalculationResult(name, a, b, calculation(a, b)))
    }

    // the calculation request (body of the POST request): the properties missing from the JSON are null
    data class CalculationRequest(val operation: String?, val a: Double?, val b: Double?)

    // the result of a calculation (body of the response)
    data class CalculationResult(val operation: String, val a: Double, val b: Double, val result: Double)
}

Comments:

  • Lines 20–25: the GET request. The [operation] parameter of the function is linked to the route variable [{operation}] ([@PathVariable]). The parameters [a] and [b], annotated as [@RequestParam], are linked to the parameters of the same name in the query string ([?a=1.5&b=2]). They are optional: a missing parameter defaults to [null]. In Java, you had to write it as ([@RequestParam(required = false) String a]); in Kotlin, you simply declare the parameter as nullable, [String?]: Spring reads the nullability of the type via [kotlin-reflect] and infers that the parameter is optional. Conversely, a non-nullable parameter ([String]) is required: Spring would reject a request missing this parameter with a 400 response. The declared type therefore tells both the compiler and Spring what might be missing;
  • lines 23–24: [a] and [b] are declared as type [String?]. We could have declared them as type [Double?]: Spring would then have performed the conversion itself and rejected an incorrect value with a 400 response to the generic message. By receiving them as strings, we retain control over the error messages;
  • line 25: the function has an expression body. Its return type, [ResponseEntity<*>], is explicitly specified: the asterisked type [*] (Chapter 4) denotes a response whose body type is unspecified, equivalent to Java’s [ResponseEntity<?>];
  • lines 29–31: the request POST. The parameter [request], annotated as [@RequestBody], is constructed by Jackson from the request’s body JSON. The properties of the [CalculationRequest] class are nullable (line 79): a property missing from the JSON document is then set to [null], which allows it to be detected;
  • Line 35: Converting a string to a number; if the conversion fails ([null]), it is written as an expression. The operator [?.] propagates the [null] of a missing string; the Kotlin function [toDoubleOrNull] returns [null] instead of throwing an exception; the function [takeIf] returns its object if the condition is true, [null] otherwise: here, it rejects the values [NaN] and infinities, which [toDoubleOrNull] accepts (texts [NaN] and [Infinity]). In both URL and JSON, numbers are written with a decimal point: [toDoubleOrNull], which is independent of the system language, is therefore the correct tool (and not [NumberFormat], which would expect a comma on a French server; see Chapter 5). In Java, a block of [try]/[catch] was needed around [Double.parseDouble];
  • line 40: the operation name, in lowercase. The Kotlin function [lowercase] performs the conversion regardless of the system language (it uses [Locale.ROOT], which had to be specified in Java);
  • lines 42–47: the requested operation is represented by a function of type [(Double, Double) -> Double] (Chapter 7), which is nullable: [((Double, Double) -> Double)?]. A [when] expression selects the lambda corresponding to the operation; [null] is used for an unknown operation. The declared type of *[calculation] allows the compiler to infer the types of the lambda parameters *[x] and *[y]*;
  • lines 50–64: The three parameters are validated. If any of them is incorrect, an error list is constructed: a dictionary that associates each parameter with its list of error messages. The function [buildMap], which belongs to the same family as [buildString] and [buildList] (Chapter 5), creates a mutable dictionary, passing it as a receiver ([this]) to the block, where [put] fills it and then sets it to read-only; it preserves the insertion order;
  • lines 60–64: the 400 response. The Spring class [ProblemDetail] represents a “Problem Details” document: [forStatus] creates it with a status code. The [apply] function (Chapter 4) executes the block with the created object as the receiver, then returns that object: its title is set by writing the [title] property (Kotlin treats the Java methods [getTitle]/[setTitle] as properties) and, using [setProperty], adds any property—in this case, [errors], the list of errors. The class [ResponseEntity] represents a complete HTTP response: status code, headers, and body. [ResponseEntity.badRequest()] prepares a 400 response, and [body] provides its body;
  • line 66: After the [return] on line 64, the compiler knows that [calculation], [a], and [b] are not [null]: these are local variables (or parameters) that can no longer change. It now treats them as values of non-nullable types [(Double, Double) -> Double] and [Double]: this is the “smart cast” discussed in Chapter 3. We structured the test to take advantage of this: the Java code tested each parameter separately and accumulated errors in a dictionary, then checked whether the dictionary was empty; however, this final check would not tell the compiler anything about [a] and [b];
  • Lines 68–72: Division by zero results in a 400 response, and this time the document includes a detail ([forStatusAndDetail]). This rejection is necessary: dividing a [Double] by zero does not throw an exception but returns infinity ([Double.POSITIVE_INFINITY]), which a JSON document cannot represent (Jackson would write it as a string, ["Infinity"]). The test [b == 0.0] compares two [Double] values: the literal must be [0.0], because Kotlin refuses to compare a [Double] with a [Int] ([b == 0] causes a compilation error for [operator '==' cannot be applied to 'Double' and 'Int']);
  • line 75: the 200 response, whose body is the [CalculationResult] object serialized to JSON. [calculation(a, b)] calls the function selected above;
  • lines 79–82: the two types exchanged with clients. These objects, which serve only to transport data between two applications, are called DTO (Data Transfer Object). Data classes are ideal for this purpose. The properties of [CalculationRequest], received from the client, are nullable; those of [CalculationResult], produced by the service, are not.

The two controller functions return the type [ResponseEntity<*>]: a response whose body can be of any type, in this case a [CalculationResult] or a [ProblemDetail]. Spring examines the actual type of the body to serialize it; it assigns the document the type [application/json], or [application/problem+json] for a [ProblemDetail].

13.3.2. Tests with curl

The service is launched by [gradlew :WsOperations:bootRun]. An addition:

1
2
3
4
5
6
7
> curl -s -i "http://localhost:5110/add?a=1.5&b=2"
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 04 Oct 2026 11:05:32 GMT

{"operation":"add","a":1.5,"b":2.0,"result":3.5}

The number 2 has become 2.0: Jackson always writes a [Double] with its decimal part. For a client—JavaScript in particular—it’s the same number. A division by zero:

1
2
3
4
5
6
7
8
> curl -s -i "http://localhost:5110/divide?a=1&b=0"
HTTP/1.1 400
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 04 Oct 2026 11:05:32 GMT
Connection: close

{"detail":"The number b must be non-zero for a division","instance":"/divide","status":400,"title":"Division by zero"}
  • Line 2: The request is denied;
  • line 3: the body is a “Problem Details” document;
  • line 6: After a 400 response, Tomcat closes the connection as a precaution: an invalid request could have left unexpected data in the connection. The client will open another connection for its next request;
  • line 8: the document contains the properties [title], [status], and [detail], set by our code, and [instance], added by Spring: the path of the request that caused the error. The property [type]—a URL that documents the error type—is missing: the RFC 9457 then assigns it the value [about:blank], which means that the status code is sufficient to describe the problem. Note that the properties are sorted alphabetically: this is the default behavior of Jackson 3 for a regular Java class such as [ProblemDetail], whose objects are not created by a parameterized constructor; data classes, as we have seen, retain the order of their primary constructor.

An unknown operation, a number written with a decimal comma, and a missing number:

1
2
3
4
5
6
7
8
> curl -s -i "http://localhost:5110/power?a=1,5"
HTTP/1.1 400
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 04 Oct 2026 11:05:32 GMT
Connection: close

{"instance":"/power","status":400,"title":"Incorrect parameters","errors":{"operation":["Unknown operation [power]: use add, subtract, multiply or divide"],"a":["The number a is missing or incorrect"],"b":["The number b is missing or incorrect"]}}

The document’s [errors] property contains the validation errors, parameter by parameter; added by [setProperty], it is placed after the normalized properties. The parameter [b], which is missing from URL, was indeed received as [null]: it is optional because it is nullable. Now a request for POST. The [-d] option of [curl] sends the text that follows it in the body of a POST request, and the [-H] option adds a header to the request, in this case the type of the document being sent:

1
2
3
4
5
6
7
> curl -s -i -H "Content-Type: application/json" -d '{"operation":"multiply","a":1.5,"b":-4}' http://localhost:5110/calculation
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 04 Oct 2026 11:05:33 GMT

{"operation":"multiply","a":1.5,"b":-4.0,"result":-6.0}

A property missing from the document JSON, here [operation], is received as [null] thanks to the nullable type of the [operation] property of [CalculationRequest]; the operation is then unknown:

1
2
3
4
5
6
7
8
> curl -s -i -H "Content-Type: application/json" -d '{"a":1,"b":2}' http://localhost:5110/calculation
HTTP/1.1 400
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 04 Oct 2026 11:05:33 GMT
Connection: close

{"instance":"/calculation","status":400,"title":"Incorrect parameters","errors":{"operation":["Unknown operation []: use add, subtract, multiply or divide"]}}

If the JSON document sent is incorrect, Jackson cannot construct the [request] parameter: Spring returns a 400 error on its own, without executing our function:

1
2
3
4
5
6
7
8
> curl -s -i -H "Content-Type: application/json" -d '{"operation":"multiply","a":1.5,' http://localhost:5110/calculation
HTTP/1.1 400
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 04 Oct 2026 11:05:33 GMT
Connection: close

{"detail":"Failed to read request","instance":"/calculation","status":400,"title":"Bad Request"}

The “Problem Details” document on line 8 was generated by Spring MVC, thanks to the [spring.mvc.problemdetails.enabled] property; without it, we would get the Spring Boot error document seen with [WsHello]. The same applies if the [Content-Type] header is omitted: [curl] then sends the body as a HTML form, and Spring rejects this type of document:

1
2
3
4
5
6
7
8
> curl -s -i -d 'x' http://localhost:5110/calculation
HTTP/1.1 415
Accept: application/json, application/*+json
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 04 Oct 2026 11:05:33 GMT

{"detail":"Content-Type 'application/x-www-form-urlencoded;charset=UTF-8' is not supported.","instance":"/calculation","status":415,"title":"Unsupported Media Type"}

Error code 415 (Unsupported Media Type) means that the type of document sent is not accepted; the [Accept] header on line 3 tells the client which types are accepted.

13.3.3. The client side

Image

Our console client requests the operation and the two numbers on a single line, then queries the service twice: first with GET, then with POST, to demonstrate both methods. Its [build.gradle.kts] file is identical to [ClientWsHello], except for the project and main class names ([clientwsoperations.MainKt]). Its program is as follows:

package clientwsoperations

import java.io.IOException
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse.BodyHandlers
import java.text.NumberFormat
import java.text.ParsePosition
import tools.jackson.databind.json.JsonMapper

// the objects exchanged with the service (the property names are those of the JSON)
data class CalculationRequest(val operation: String, val a: Double, val b: Double)

data class CalculationResult(val operation: String, val a: Double, val b: Double, val result: Double)

// an error response in the "Problem Details" format: the properties that may be missing are nullable
data class Problem(
    val title: String?,
    val detail: String?,
    val status: Int?,
    val errors: Map<String, List<String>>?,
)

// the numbers are typed and displayed in the current culture (fr-FR: decimal comma, en-US: decimal point)
private val FORMAT = NumberFormat.getInstance()

// console client of the WsOperations web service
fun main(args: Array<String>) {
    val serviceUrl = URI.create(args.firstOrNull() ?: "http://localhost:5110/")
    // the JSON converter; Jackson 3 ignores the unknown JSON properties (instance...)
    val mapper = JsonMapper.builder().build()

    HttpClient.newHttpClient().use { client ->
        while (true) {
            // input of the operation and of the two numbers (decimal separator of the current culture)
            print("Operation a b (e.g.: add 1.5 2), nothing to stop: ")
            val input = readlnOrNull()?.trim() ?: ""
            if (input.isEmpty()) {
                break
            }
            val fields = input.split(Regex("""\s+"""))
            if (fields.size != 3) {
                println("Three elements are needed: operation a b")
                continue
            }
            // local check of the two numbers, before any call to the service
            val (operation, textA, textB) = fields
            val a = readNumber(textA)
            if (a == null) {
                println("Incorrect number a...")
            }
            val b = readNumber(textB)
            if (b == null) {
                println("Incorrect number b...")
            }
            if (a == null || b == null) {
                continue
            }
            val calculation = "$operation(${FORMAT.format(a)} ; ${FORMAT.format(b)})"

            // 1 - GET request: in the URL, the numbers are written with a decimal point
            val url = "${URLEncoder.encode(operation, Charsets.UTF_8)}?a=$a&b=$b"
            try {
                val request = HttpRequest.newBuilder(serviceUrl.resolve(url)).GET().build()
                val response = client.send(request, BodyHandlers.ofString())
                if (response.statusCode() == 200) {
                    val result = mapper.readValue(response.body(), CalculationResult::class.java)
                    println("[GET $url] $calculation = ${FORMAT.format(result.result)}")
                } else {
                    // failure: the status code and the document received are displayed, as they are
                    println("[GET $url] error ${response.statusCode()} : ${response.body()}")
                }
            } catch (e: IOException) {
                // the service is unreachable
                println("[GET $url] error: $e")
            }

            // 2 - POST request: the request is sent in JSON in the body of the request
            try {
                val json = mapper.writeValueAsString(CalculationRequest(operation, a, b))
                val request = HttpRequest.newBuilder(serviceUrl.resolve("calculation"))
                    .header("Content-Type", "application/json")
                    .POST(HttpRequest.BodyPublishers.ofString(json))
                    .build()
                val response = client.send(request, BodyHandlers.ofString())
                if (response.statusCode() == 200) {
                    // success: the body contains the result
                    val result = mapper.readValue(response.body(), CalculationResult::class.java)
                    println("[POST calculation] $calculation = ${FORMAT.format(result.result)}")
                } else {
                    // failure: the body contains the description of the problem
                    val problem = mapper.readValue(response.body(), Problem::class.java)
                    println("[POST calculation] error ${response.statusCode()} : ${problem.title}")
                    problem.detail?.let { println("  $it") }
                    problem.errors?.forEach { (parameter, messages) ->
                        println("  $parameter : ${messages.joinToString(" ; ")}")
                    }
                }
            } catch (e: IOException) {
                // the service is unreachable
                println("[POST calculation] error: $e")
            }
        }
    }
}

// a number typed in the current culture, null if the input is not one
fun readNumber(text: String): Double? {
    val position = ParsePosition(0)
    val number = FORMAT.parse(text, position)
    // the input must have been read entirely: "2.5x" is refused
    return if (number == null || position.index != text.length) null else number.toDouble()
}

Comments:

  • Lines 14–23: the client’s DTO files. They are declared here again: a client does not share the server’s code, only the format of the exchanged JSON documents. The properties of [CalculationRequest] are not nullable: the client sends only complete requests. Spring’s [ProblemDetail] class is part of the web framework; a simple data class is sufficient for the client. Its properties are nullable: a property missing from the document, such as [detail] or [errors], is treated as [null];
  • line 27: Numbers are entered and displayed in the system language’s format, in this case English: decimal point. [FORMAT] is a top-level property, private to the file;
  • line 33: Starting with Jackson 3, properties JSON that are unknown to the class (here, [instance]) are ignored by default during deserialization;
  • lines 43–49: the input is split into fields using a regular expression (one or more spaces), then the list of three elements is unpacked into three variables (Chapter 3): the declaration [val (operation, textA, textB) = fields] calls the functions [component1()], [component2()], and [component3()] from the list;
  • Lines 50–58: The numbers are verified locally before any service call is made. A service must always verify its parameters (since it does not know who is calling it), but a client can avoid unnecessary network traffic. After [continue] on line 59, the compiler knows that [a] and [b] are not [null]: they are now of type [Double] (intelligent type conversion);
  • line 61: the text describing the calculation, with the numbers in the format of the system language;
  • line 64: the URL from the GET query. Numbers must be written with a decimal point: a [Double] inserted into a string template is converted by its function [toString], which is independent of the system language. The operation, entered by the user, is encoded as [URLEncoder];
  • lines 66–67: the request GET;
  • lines 68–70: if successful (status code 200), the response JSON is deserialized into a [CalculationResult] object;
  • lines 72–73: if the request fails, the status code and the received document are displayed as-is;
  • lines 75–77: the exception [IOException] indicates a communication error, such as an unreachable service;
  • lines 82–86: the POST request. The [CalculationRequest] object is serialized into JSON by [writeValueAsString]; the request receives the header [Content-Type], followed by its body: [BodyPublishers.ofString] sends a string, encoded as UTF-8;
  • Lines 94–98: If the validation fails, the body is a “Problem Details” document, deserialized into a [Problem] object, whose title, details, and validation errors are displayed. The nullable properties [detail] and [errors] are handled by the operator [?.]: [problem.detail?.let { ... }] executes the block only if the detail exists ([it] is then of type [String]), and [problem.errors?.forEach { ... }] only iterates through the dictionary if it exists. The lambda passed to [forEach] receives each element of the dictionary ([Map.Entry]), unpacked into [(parameter, messages)] (Chapter 7); the function [joinToString] combines the messages from a parameter;
  • lines 110–114: reading a number in the format of the system language. The method [parse(String, ParsePosition)] of [NumberFormat] reads the beginning of the string and indicates in the object [ParsePosition] where it stopped (property [index], Java method [getIndex()]): if it did not stop at the end, the input contains extra characters ([2.5x]). [parse] returns a [Number] (or [null]), converted by [toDouble()].

Results, with the service running (inputs replaced after the queries):

> gradlew -q :ClientWsOperations:run
Operation a b (e.g.: add 1.5 2), nothing to stop: add 1.5 2
[GET add?a=1.5&b=2.0] add(1.5 ; 2) = 3.5
[POST calculation] add(1.5 ; 2) = 3.5
Operation a b (e.g.: add 1.5 2), nothing to stop: divide 1 0
[GET divide?a=1.0&b=0.0] error 400 : {"detail":"The number b must be non-zero for a division","instance":"/divide","status":400,"title":"Division by zero"}
[POST calculation] error 400 : Division by zero
  The number b must be non-zero for a division
Operation a b (e.g.: add 1.5 2), nothing to stop: power 2 3
[GET power?a=2.0&b=3.0] error 400 : {"instance":"/power","status":400,"title":"Incorrect parameters","errors":{"operation":["Unknown operation [power]: use add, subtract, multiply or divide"]}}
[POST calculation] error 400 : Incorrect parameters
  operation : Unknown operation [power]: use add, subtract, multiply or divide
Operation a b (e.g.: add 1.5 2), nothing to stop: multiply 2.5 x
Incorrect number b...
Operation a b (e.g.: add 1.5 2), nothing to stop:
  • line 3: the URL from the query GET: the number 2 entered there is written as 2.0;
  • Lines 6–8: The same error observed in both queries: the raw document for query GET, and its processing for query POST;
  • Lines 10–12: The unknown operation was detected by the service;
  • line 14: the incorrect number was detected by the client, which did not query the service.

13.4. Sample Application—Version 9: A Web Service for Calculating Taxes

We’ll revisit the tax calculation application. In the previous chapter (version 8), we had created a server named TCP that could be queried remotely, using its own communication protocol. We’ll now turn it into a web service named REST.

13.4.1. The Architecture

The architecture of version 9 is as follows:

Image

  • on the server side, the [WsTax] web service receives HTTP requests and has them processed by the [business] and [dao] layers from Version 5 (Chapter 6), which are reused as-is;
  • on the client side, the [ui] layer from version 5 is also reused as-is. It communicates with a new type of [business] layer, [TaxBusinessHttp], which implements the [ITaxBusiness] interface by querying the web service: it acts as a proxy for the service. The [ui] layer is unaware that the calculation is performed on another machine;
  • on both sides, the integration of the layers is handled by Spring’s dependency injection container, as in version 5.

13.4.2. The web service

The service contract is as follows:

Request
Response on success
Response in case of an error
GET /tax?married=y&children=2&salary=60000
200: [{"tax":4282}]
400: "Problem Details" document
POST /tax with the body [{"married":true,"children":2,"salary":60000}]
200: [{"tax":4282}]
400: "Problem Details" document

The [WsTax] project depends on the [tax-dao] and [tax-business] modules from version 5. Thanks to the composite build declared in [settings.gradle.kts] ([includeBuild("../06-architectures")]), Gradle finds them directly in the Chapter 6 folder and builds them as needed: unlike with Maven, there is no need to install them in a local repository beforehand. The dependencies of the [build.gradle.kts] file from [WsTax] are as follows (the rest of the file is the same as [WsHello], except for the project coordinates: [examples.tax:WsTax:9.0]):

1
2
3
4
5
6
7
8
9
dependencies {
    implementation(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES))
    // Spring MVC, the embedded Tomcat server and Jackson
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    // the [dao] and [business] layers of version 5, reused as they are (composite build):
    // tax-business also brings tax-dao, which is part of its API
    implementation("examples.tax:tax-business:5.0")
}
  • lines 2–5: the dependencies of any Spring Boot service written in Kotlin;
  • line 8: the [tax-business] module from version 5, identified by its coordinates. It declares the module [tax-dao] as a dependency of its API ([api(project(":TaxV5:tax-dao"))], Chapter 6): this module is therefore also visible to the service, along with the classes [ITaxDao], [JsonTax]… from the [dao] and [TaxBracket] layers, and [TaxException] from the [entities] layer. The [tax-dao] module depends on Jackson 3, which the web starter already provides; therefore, the version selected by the Spring Boot dependency list is used.

The configuration file [application.properties] is as follows:

# the name of the application
spring.application.name=WsTax
# the listening port of the Tomcat server
server.port=5120
# the errors detected by Spring MVC are reported in the "Problem Details" format (RFC 9457)
spring.mvc.problemdetails.enabled=true
# no banner at startup
spring.main.banner-mode=off
# shorter log messages: time, level, thread, class, message
logging.pattern.console=%d{HH:mm:ss.SSS} %-5level [%thread] %logger{0} : %msg%n

# the [dao] layer: its implementation and its data file
dao.implementation=JsonTax
dao.file=TaxData.json
  • line 4: the service listens on port 5120;
  • Lines 13–14: the configuration for the [dao] layer, in the same format as in the [tax.properties] file from version 5: the implementation to use and its data file. These are not Spring Boot properties, but properties specific to our application: a [application.properties] file can contain as many as desired. Here we are using the files JSON and [TaxData.json], located in the project folder, which is the service’s runtime folder along with [gradlew :WsTax:bootRun].

The [dao] and [business] layers are integrated into the Spring container via a configuration class:

package tax.ws

import tax.dao.FileTax
import tax.dao.HardwiredTax
import tax.dao.ITaxDao
import tax.dao.JsonTax
import tax.entities.TaxException
import tax.business.ITaxBusiness
import tax.business.TaxBusiness
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

// the dao.* properties of the configuration, gathered in an object
// the default values of the parameters are those of the missing properties
@ConfigurationProperties("dao")
data class DaoProperties(val implementation: String = "HardwiredTax", val file: String = "")

// the integration of the [dao] and [business] layers of version 5 in the Spring container
@Configuration
@EnableConfigurationProperties(DaoProperties::class)
class TaxConfig {

    // the [dao] layer: its implementation and its file come from the configuration
    @Bean
    fun dao(properties: DaoProperties): ITaxDao = when (properties.implementation) {
        "HardwiredTax" -> HardwiredTax()
        "FileTax" -> FileTax(properties.file)
        "JsonTax" -> JsonTax(properties.file)
        else -> throw TaxException(
            "Unknown implementation [${properties.implementation}] of the [dao] layer"
        )
    }

    // the [business] layer: Spring passes it the [dao] layer
    @Bean
    fun business(dao: ITaxDao): ITaxBusiness = TaxBusiness(dao)
}

Comments:

  • Lines 17–18: The [dao.*] configuration properties are grouped into an object of type [DaoProperties], a data class. The [@ConfigurationProperties("dao")] annotation instructs Spring Boot to map the properties with the prefix [dao] to the constructor parameters of the same name: [dao.implementation] to the parameter [implementation], [dao.file] to the parameter [file]. The default values of the parameters are used for properties missing from the configuration: Spring Boot, which reads the primary constructor and its default values via [kotlin-reflect], knows how to call the constructor without the missing arguments. We verified this by launching the service without the [application.properties] file: it used the [HardwiredTax] implementation. This binding is typed (a property of type [Int] or [Boolean] would be converted, and an incorrect value would prevent startup), and the resulting object is immutable;
  • line 22: the annotation registers the [DaoProperties] class with Spring Boot, which creates the object at startup and makes it a bean. You can also place [@ConfigurationPropertiesScan] on the main class: Spring Boot will then search for classes annotated with [@ConfigurationProperties] on its own;
  • Lines 21–23: The [@Configuration] annotation (Chapter 6): The class defines beans. Spring finds it because it is located in the same package as the main class ([@ComponentScan]). The [plugin.spring] plugin makes it open, along with its functions;
  • Lines 27–32: The annotated function [@Bean] creates the layer [dao]. Its parameter is provided by Spring: it is the bean [DaoProperties]. A [when] expression selects the implementation; an unknown implementation triggers a [TaxException] ([throw] is a Kotlin expression: it can appear in a branch of [when]);
  • line 38: the [business] layer. The function’s [dao] parameter receives the bean of type [ITaxDao]: Spring therefore creates the [dao] layer before the [business] layer.

Chapter 6 had injected each property separately using the [@Value] annotation: [@Value("\${dao.implementation}") implementation: String]. This approach is still possible with Spring Boot; note that in Kotlin, the character [$] in [${...}] must be escaped with a backslash, otherwise the compiler would interpret it as a string template. [@ConfigurationProperties] is preferable whenever a group of properties belongs together: the configuration is described by a class, which is typed and validated at startup, and it is injected from a block wherever it is needed. The functions [dao] and [business] replicate those of the [TaxConfig] class from version 5, without the [@PropertySource] annotation: Spring Boot reads the [application.properties] file itself.

These lines replace the layer integration that was handled by the main program in version 5: the Spring container is the same, but Spring Boot now creates it and locates the configuration class on its own. The beans defined this way are singletons, created when the application starts up, before any requests are processed: if the data file is missing or incorrect, the service does not start, rather than responding with a 500 error to all requests. We’ll verify this later.

The web service is the [TaxController] controller:

package tax.ws

import tax.business.ITaxBusiness
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

// the tax calculation web service (version 9)
// the [business] layer is injected by Spring into the primary constructor
@RestController
class TaxController(private val business: ITaxBusiness) {

    // GET /tax?married=y&children=2&salary=60000
    @GetMapping("/tax")
    fun calculateGet(
        @RequestParam married: String?,
        @RequestParam children: String?,
        @RequestParam salary: String?,
    ): TaxResponse {
        val isMarried = when (married?.lowercase()) {
            "y" -> true
            "n" -> false
            else -> null
        }
        return calculate(isMarried, children?.toIntOrNull(), salary?.toIntOrNull())
    }

    // POST /tax with the JSON body {"married":true,"children":2,"salary":60000}
    @PostMapping("/tax")
    fun calculatePost(@RequestBody request: TaxRequest): TaxResponse =
        calculate(request.married, request.children, request.salary)

    // the processing common to the two HTTP methods: the tax, or an exception if the request is incorrect
    private fun calculate(married: Boolean?, children: Int?, salary: Int?): TaxResponse {
        // correct parameters: the calculation is requested from the [business] layer
        if (married != null && children != null && children >= 0 && salary != null && salary >= 0) {
            val tax = business.calculateTax(married, children, salary)
            log.info("Tax(married={}, children={}, salary={}) = {}", married, children, salary, tax)
            return TaxResponse(tax)
        }
        // incorrect parameters: the list of errors
        val errors = buildMap {
            if (married == null) {
                put("married", listOf("Indicate whether the taxpayer is married: y/n (GET) or true/false (POST)"))
            }
            if (children == null || children < 0) {
                put("children", listOf("The number of children must be a positive or zero integer"))
            }
            if (salary == null || salary < 0) {
                put("salary", listOf("The salary must be a positive or zero integer"))
            }
        }
        log.warn("Incorrect request: {}", errors.keys.joinToString())
        throw BadRequestException(errors)
    }

    // an incorrect request gives a 400 response in the "Problem Details" format
    @ExceptionHandler
    fun badRequest(e: BadRequestException): ProblemDetail =
        ProblemDetail.forStatus(HttpStatus.BAD_REQUEST).apply {
            title = "Incorrect parameters"
            setProperty("errors", e.errors)
        }

    // the calculation request (body of the POST request): the properties missing from the JSON are null
    data class TaxRequest(val married: Boolean?, val children: Int?, val salary: Int?)

    // the response: the amount of the tax
    data class TaxResponse(val tax: Int)

    companion object {
        // the log of the class
        private val log = LoggerFactory.getLogger(TaxController::class.java)
    }
}

Comments:

  • Line 17: The controller receives the [business] layer via its primary constructor. Spring, which creates the controller, passes it the [ITaxBusiness] bean defined by [TaxConfig]: this is constructor injection as discussed in Chapter 6. The [business] property, declared as [private val] in the constructor, ensures that the layer will not change. The Java code required a [final] field and a four-line constructor;
  • lines 20–31: the query GET. The three parameters of the query string are received as nullable strings (and are therefore optional) and converted: [y]/[n] to [isMarried] (an expression [when] whose type is [Boolean?], with the value [null] of [married?.lowercase()] falling into the branch [else]), integers for [children] and [salary] via [toIntOrNull] and [null] in case of an error;
  • lines 35–37: the query POST: the body JSON is deserialized into an object [TaxRequest] (line 72), whose nullable properties ([Boolean?], [Int?]) allow missing values to be detected. The same URL [/tax] is associated with two functions, depending on the HTTP method of the query;
  • lines 40–60: the processing common to both queries. It returns a response if successful and throws an exception if an error occurs. The result type, like that of the functions [calculateGet] and [calculatePost], is therefore simply [TaxResponse]: by reading their signatures, we can see what the service returns when everything goes well;
  • Line 42: Are the three parameters correct? The comparisons [children >= 0] and [salary >= 0] are evaluated only if they are not [null], thanks to the lazy evaluation of the operator [&&]; the compiler knows this and treats [children] as [Int] starting with the comparison that follows the test. Within the block, [married], [children], and [salary] are non-nullable types: they can be passed to the [calculateTax] function in the [business] layer, whose parameters are of types [Boolean] and [Int];
  • line 43: the calculation is delegated to the [business] layer of version 5;
  • line 44: Each calculation is logged at the [INFO] level. The message text is a template: each [{}] is replaced by the corresponding argument, in order. Unlike a Kotlin string template (["Tax(married=$married...)"]), the message is constructed only if it needs to be displayed (depending on the log level), and logging tools can record each value separately;
  • lines 48–60: In the event of an error, the error list is constructed, a warning ([warn]) is logged, and a [BadRequestException] exception is thrown that carries this list;
  • lines 64–68: An annotated function [@ExceptionHandler] acts as an exception handler: Spring calls it when a controller function throws an exception of the type specified by its parameter, in this case [BadRequestException]. What it returns becomes the HTTP response: a [ProblemDetail], whose status code (400) becomes the response’s status code. The document is constructed and rendered in a single expression, using [apply];
  • lines 72–75: the service’s DTO;
  • lines 77–79: the class log. Spring Boot uses the API logging framework SLF4J (Simple Logging Facade for Java), which is implemented here using Logback. [LoggerFactory.getLogger] returns a log entry whose category is the full name of the class. The log is shared by all instances of the class: it is stored in its companion object, the equivalent of the [static] field in Java (Chapter 4). It can also be declared as a top-level private property in the file.

The [BadRequestException] exception is as follows:

1
2
3
4
5
6
package tax.ws

// the exception thrown by the controller when the calculation parameters are incorrect
// it carries the error messages, parameter by parameter
class BadRequestException(val errors: Map<String, List<String>>) :
    RuntimeException("Incorrect parameters: ${errors.keys.joinToString()}")
  • Line 5: The error list is a property declared in the primary constructor, which is also used to construct the message passed to the parent class’s constructor, [RuntimeException]. In Kotlin, all exceptions are unchecked: no function needs to declare them. The three lines of this class replace the twenty or so lines of the Java version: there are no accessors to write and no serialization version number ([serialVersionUID]) to declare, as the Kotlin compiler does not issue the corresponding warning that the Java compiler does; and if we wanted to exclude the [errors] property from binary serialization—as the Java keyword [transient] did—we would annotate it as [@Transient]. Our exceptions are never serialized.

The main class [WsTaxApplication.kt] is identical to that of the other two services. The [src/main/resources/static] folder contains the web client, which is discussed in the following section.

The web service is stateless: the controller functions do not retain any state from one request to the next. The controller and the layers [business] and [dao] are singletons, created once and for all, that exist for as long as the application does; they are shared by all requests, which may be processed simultaneously by different threads of the Tomcat server (Chapter 8). This isn’t a problem here because these objects are never modified after they are created: the tax brackets are read once and for all, and the controller has only one property, [val]. A controller should never store request-specific data in its properties. Singleton is the default scope for Spring beans; but there are others: [prototype] (one object per request to the container), [request] (one object per request HTTP), [session] (one object per session HTTP). Finally, note that the [spring.threads.virtual.enabled=true] property causes each request to be processed by a virtual thread (Java 21, Chapter 8) instead of a thread from the Tomcat pool: the service can then handle a very large number of concurrent requests that are waiting for I/O operations. Spring MVC also supports controller functions [suspend] (Chapter 9); these features are particularly useful with Spring WebFlux, the non-blocking variant of Spring MVC, where a request does not occupy any thread while waiting.

13.4.3. Running the Service

Let’s start with a configuration error. Command-line arguments of the form [--property=value] are part of the application configuration and override the values in [application.properties]. With Gradle, these are passed to the [bootRun] task using the [--args] option. Let’s look at a non-existent data file (the course has abbreviated the lines of this log—which are very long—to [...] and omitted the Gradle messages):

> gradlew :WsTax:bootRun --args=--dao.file=missing.json
13:08:53.853 INFO  [main] WsTaxApplicationKt : Starting WsTaxApplicationKt using Java 25.0.4 with PID 22991 (...)
...
13:08:55.064 INFO  [main] TomcatWebServer : Tomcat initialized with port 5120 (http)
...
13:08:55.224 WARN  [main] AnnotationConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'taxController' ... Error creating bean with name 'business' ... Error creating bean with name 'dao' ... Factory method 'dao' threw exception with message: Error while reading the file missing.json
13:08:55.226 INFO  [main] StandardService : Stopping service [Tomcat]
...
13:08:55.254 ERROR [main] LoggingFailureAnalysisReporter :

***************************
APPLICATION FAILED TO START
***************************

Description:

The service cannot start: Error while reading the file missing.json (missing.json: The file [missing.json] does not exist)

Action:

Check the dao.implementation and dao.file properties of the configuration.
  • line 4: the Tomcat server was created, but it has not yet opened its port;
  • line 6: bean creation failed. To create the controller, Spring had to create the [business] layer, and therefore the [dao] layer, whose constructor instantiated a [TaxException] (Chapter 6). The messages for our layers, like those for Spring, are in English;
  • line 7: the Tomcat server is stopped; the service did not accept any requests;
  • lines 9–21: the startup failure report. Its description (line 17) and the recommended action (line 21) are generated by a class we wrote for this purpose.

In fact, when startup fails, Spring Boot normally displays the exception and the entire call stack: dozens of lines, where the useful message is hard to find. Spring Boot can also pass the exception to failure analyzers, which translate it into a readable report. It provides analyzers for the most common errors (port already in use, bean not found, etc.), and we can add our own:

package tax.ws

import tax.entities.TaxException
import org.springframework.boot.diagnostics.AbstractFailureAnalyzer
import org.springframework.boot.diagnostics.FailureAnalysis

// Spring Boot calls this analyzer if the startup fails because of a TaxException
// (missing or incorrect data file, unknown implementation)
class TaxFailureAnalyzer : AbstractFailureAnalyzer<TaxException>() {

    override fun analyze(rootFailure: Throwable, cause: TaxException): FailureAnalysis {
        // the description of the error: the message of the exception and that of its cause
        val description = "The service cannot start: ${cause.message}" +
            (cause.cause?.let { " (${it.message})" } ?: "")
        // what the user must do
        val action = "Check the dao.implementation and dao.file properties of the configuration."
        return FailureAnalysis(description, action, cause)
    }
}
  • Line 9: Spring Boot’s abstract class *[AbstractFailureAnalyzer<T>]* searches for an exception of type [T]*—in this case, *[TaxException]*—in the chain of causes leading to the exception that caused the startup to fail. If it finds one, it calls the function [analyze]. Our class is derived from it: the parentheses in [AbstractFailureAnalyzer<TaxException>()]* represent the call to its constructor;
  • line 11: the parameter [rootFailure] is the original exception, and [cause] is the [TaxException] found in the chain. The redefinition is indicated by the keyword [override], which is required in Kotlin. In the Spring Boot Java class, the method may return [null] (“I cannot parse this exception”); our override declares a non-nullable return type, [FailureAnalysis], which Kotlin allows: an override can restrict the type of its return value;
  • lines 13–14: the error description: the exception message, followed in parentheses by the message of its possible cause. [cause.cause?.let { ... } ?: ""] returns the text in parentheses if the cause exists, an empty string otherwise;
  • lines 16–17: the report is a [FailureAnalysis] object consisting of a description, a recommended action, and the analyzed exception.

This parser is not a bean (it must function even if the container could not be created): Spring Boot searches for it in the [META-INF/spring.factories] files of the libraries and the application. Ours is located in [src/main/resources/META-INF]:

# the analyzer of the startup failures caused by a TaxException
org.springframework.boot.diagnostics.FailureAnalyzer=tax.ws.TaxFailureAnalyzer
  • line 2: the key is the name of the [FailureAnalyzer] interface, and the value is the name of our class (multiple classes would be separated by commas).

Now let’s start the service as usual, using [gradlew :WsTax:bootRun], and then query it with [curl]:

1
2
3
4
5
6
7
> curl -s -i "http://localhost:5120/tax?married=y&children=2&salary=60000"
HTTP/1.1 200
Content-Type: application/json
Content-Length: 12
Date: Sun, 04 Oct 2026 11:08:58 GMT

{"tax":4282}

This is the expected result: a married taxpayer with two children and an annual income of 60,000 euros pays 4,282 euros in taxes (note that the tax schedule used—the 2004 schedule—is retained only for this fiscal year). With incorrect parameters:

1
2
3
4
5
6
7
8
> curl -s -i "http://localhost:5120/tax?married=x&children=-2"
HTTP/1.1 400
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 04 Oct 2026 11:08:58 GMT
Connection: close

{"instance":"/tax","status":400,"title":"Incorrect parameters","errors":{"married":["Indicate whether the taxpayer is married: y/n (GET) or true/false (POST)"],"children":["The number of children must be a positive or zero integer"],"salary":["The salary must be a positive or zero integer"]}}

Then in POST, with a document JSON:

1
2
3
4
5
6
7
> curl -s -i -H "Content-Type: application/json" -d '{"married":true,"children":2,"salary":60000}' http://localhost:5120/tax
HTTP/1.1 200
Content-Type: application/json
Content-Length: 12
Date: Sun, 04 Oct 2026 11:08:58 GMT

{"tax":4282}

The service log will be shown at the end of the chapter: it contains a record of all the requested calculations.

13.4.4. The console client: a new layer, [business]

The Version 9 client reuses the [ui] layer from Version 5 without modifying it: the [tax.ui.Dialog] object, whose [run(business: ITaxBusiness)] function interacts with the user and requests calculations from the [business] layer that is passed to it. Simply pass it a [business] layer that queries the web service. The dependencies of the [ClientWsTax] project are as follows (the rest of the [build.gradle.kts] file is the same as that of [ClientWsHello], except for the project and main class names: [examples.tax:ClientWsTax:9.0], [tax.client.MainKt]):

1
2
3
4
5
6
7
8
9
dependencies {
    // the interface of the [business] layer and the [ui] layer of version 5 (composite build)
    implementation("examples.tax:tax-business:5.0")
    implementation("examples.tax:tax-ui:5.0")
    // the Spring container, used directly by the main program
    implementation("org.springframework:spring-context:7.0.9")
    // Jackson for the JSON documents exchanged with the service
    implementation("tools.jackson.core:jackson-databind:3.1.5")
}
  • Lines 3–4: the modules for version 5: [tax-business] for the interface [ITaxBusiness], [tax-ui] for the object [Dialog]. A project may depend on a module that contains a main program; in this case, it uses the module’s public classes without executing the program. The module [tax-dao] is a dependency of API, which in turn depends on [tax-business]: the client uses only the class [TaxException] from it;
  • Line 6: the Spring container. The [tax-ui] module depends on it, but declares it via [implementation] (Chapter 6): the library is present when the client runs, but it is not visible when its code is compiled. Gradle thus distinguishes between what a module uses internally and what it exposes ([api]); since our main program uses Spring directly, it must declare it itself (whereas Maven made all indirect dependencies visible);
  • Line 8: Jackson, for documents JSON. It is also provided by [tax-dao], but, again, as an internal dependency: the client, which uses it directly, must declare it.

The new layer [business] is the class [TaxBusinessHttp]:

package tax.client

import tax.entities.TaxException
import tax.business.ITaxBusiness
import java.io.IOException
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import tools.jackson.databind.json.JsonMapper

// the [business] layer on the client side: a proxy of the tax calculation web service
// it implements the same interface as the local [business] layer of version 5
class TaxBusinessHttp(private val client: HttpClient, serviceUrl: URI) : ITaxBusiness {

    // the objects exchanged with the web service
    private data class TaxRequest(val married: Boolean, val children: Int, val salary: Int)

    private data class TaxResponse(val tax: Int)

    // the URL of the POST /tax method, the JSON converter
    private val taxUrl = serviceUrl.resolve("tax")
    private val mapper = JsonMapper.builder().build()

    override fun calculateTax(married: Boolean, nbChildren: Int, salary: Int): Int {
        // the POST /tax request, with the request in JSON in its body
        val json = mapper.writeValueAsString(TaxRequest(married, nbChildren, salary))
        val request = HttpRequest.newBuilder(taxUrl)
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(json))
            .build()
        val response = try {
            // synchronous sending: the function waits for the response
            client.send(request, HttpResponse.BodyHandlers.ofString())
        } catch (e: IOException) {
            // the service is unreachable
            throw TaxException("The web service $taxUrl is unreachable", e)
        } catch (e: InterruptedException) {
            // the thread was interrupted during the wait: this is signaled again
            Thread.currentThread().interrupt()
            throw TaxException("The tax calculation was interrupted", e)
        }
        // did the service accept the request?
        if (response.statusCode() != 200) {
            throw TaxException(
                "The web service responded ${response.statusCode()} : ${response.body()}",
                code = response.statusCode(),
            )
        }
        // the JSON response {"tax":4282}
        return mapper.readValue(response.body(), TaxResponse::class.java).tax
    }
}

Comments:

  • line 14: the class implements the [ITaxBusiness] interface from version 5. Through its primary constructor, it receives the client HTTP, which it makes a private property ([private val client]), and the service’s URL, a simple constructor parameter;
  • lines 17–19: the DTO variables, declared inside the class ([private]) because no one else uses them;
  • lines 22–23: the URL from the [POST /tax] function, calculated from the service’s value during property initialization (the [serviceUrl] parameter of the primary constructor can be used in property initializers), and the converter JSON;
  • lines 27–31: the request [POST /tax], with the request serialized as JSON in its body;
  • lines 32–34: the request is sent synchronously: the [send] method waits for the response, which is appropriate for the [calculateTax] function of the interface, which is itself synchronous (it returns a [Int]). [try] is a Kotlin expression (Chapter 3): its value—in this case, the response—is that of the [try] block if everything goes well; blocks of the form [catch] that end with a [throw] (of type [Nothing]), the variable [response] is of type [HttpResponse<String>] and may be a [val], where Java was supposed to declare it before the [try];
  • Lines 35–37: [send] launches a [IOException] if the service is unreachable. It is wrapped in a [TaxException], the application exception [Tax]. Kotlin does not require this (it does not have controlled exceptions), but it is a best practice in layered architecture: the [ui] layer only handles application exceptions, not those related to the communication mechanism used by the [business] layer;
  • lines 38–41: the same applies to the [InterruptedException] exception, which is thrown if the thread is interrupted while waiting. Catching the exception clears the thread’s interruption flag: best practice (Chapter 8) is to reset it using [Thread.currentThread().interrupt()] so that the calling code knows the thread was interrupted;
  • lines 44–47: An error response is converted to [TaxException], whose code is the status code HTTP. The constructor for [TaxException] (Chapter 6) has a parameter [cause] with a default value; to set the code without specifying a cause, the argument is named [code = response.statusCode()];
  • Line 51: The response JSON [{"tax":4282}] is deserialized, and the tax amount is returned.

With a service named SOAP, the tool [wsimport] used to generate a proxy, and a class similar to this one implemented [ITaxBusiness] by delegating the calculation to it: today, we write the few dozen lines of the proxy ourselves.

Since the [calculateTax] function of the [ITaxBusiness] interface is synchronous, we used the synchronous [send] method of [HttpClient]. This also offers an asynchronous method, [sendAsync], which immediately returns a [CompletableFuture<HttpResponse<T>>]. In Kotlin, we would call it from a [suspend] function, waiting for the result using the [await()] extension function from kotlinx-coroutines (Chapter 9): [suspend fun calculateTax(married: Boolean, nbChildren: Int, salary: Int): Int]. This would require modifying the [ITaxBusiness] interface, its implementations, and the [ui] layer. We did not do this in order to keep the layers from version 5 intact. With virtual threads (Java 21), synchronous code once again becomes a reasonable way to write programs that involve a lot of I/O: a virtual thread blocked by [send] does not block any system threads.

The client’s main program integrates the layers:

package tax.client

import tax.business.ITaxBusiness
import tax.ui.Dialog
import java.net.URI
import java.net.http.HttpClient
import kotlin.system.exitProcess
import org.springframework.beans.factory.getBean
import org.springframework.context.support.GenericApplicationContext
import org.springframework.context.support.registerBean

fun main(args: Array<String>) {
    // the URL of the web service: the one passed as an argument, or the default one
    val serviceUrl = URI.create(args.firstOrNull() ?: "http://localhost:5120/")

    // integration of the layers: the [business] layer is now a client of the web service
    // the result of the [use] block is the exit code of the program
    val code = GenericApplicationContext().use { context ->
        // the URL of the service and the HTTP client, which Spring will pass to the constructor of TaxBusinessHttp
        context.registerBean<URI> { serviceUrl }
        context.registerBean<HttpClient> { HttpClient.newHttpClient() }
        context.registerBean<TaxBusinessHttp>()
        context.refresh()
        val business = context.getBean<ITaxBusiness>()

        // the dialog of version 5, without any modification
        try {
            Dialog.run(business)
            0
        } catch (e: RuntimeException) {
            // the service is unreachable or refused the request
            Dialog.displayError(e)
            1
        }
    }
    if (code != 0) {
        exitProcess(code)
    }
}

Comments:

  • line 14: the service’s URL, passed as an argument or, by default, [http://localhost:5120/];
  • line 18: the Spring container, as in version 5. It is closed at the end of the [use] block. The value of the block—its last expression—is returned by [use]: this is the program’s exit code;
  • lines 20–22: three beans are registered using Spring’s [registerBean<T>] extension functions for Kotlin (Chapter 6): the service’s URL and the client’s HTTP, provided by lambdas, followed by the [TaxBusinessHttp] class. To create this class, Spring examines its primary constructor and passes it the beans of types [HttpClient] and [URI];
  • line 23: the container creates the beans;
  • line 24: the container is asked for a bean of type [ITaxBusiness]; it returns the object [TaxBusinessHttp], which implements this interface. The rest of the program is unaware of the implementation;
  • line 28: the dialog from version 5, without any modifications; if it terminates normally, the value of the [try] block is its final expression, 0;
  • lines 30–32: if the service cannot be reached, the exception is propagated from the [business] layer through the [ui] layer, which does not handle it: it is displayed using the [Dialog.displayError] function from version 5, and the value of [try] is that of the [catch] block, 1;
  • Lines 36–37: If an error occurs, the program exits with code 1 after closing the container. [exitProcess] is the Kotlin function that calls [System.exit]; its return type is [Nothing], because it never returns control.

Results, with the service running (inputs replaced after the queries):

> gradlew -q :ClientWsTax:run
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Tax=4282 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 3 60000
Tax=2959 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: n 0 60000
Tax=11519 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y -2 60000
syntax: Married NbChildren Salary
Married: y for married, n for not married
NbChildren: number of children
Salary: annual salary in euros
Incorrect NbChildren argument: type a positive or zero integer
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop:

We see the check values for the tax calculation: 4,282, 2,959, and 11,519 euros. The incorrect entry on line 8 was detected by the [ui] layer, which did not call the [business] layer: the service received only correct requests. If the service is stopped:

> gradlew -q :ClientWsTax:run
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Exception chain:
----------------------------------------
tax.entities.TaxException: The web service http://localhost:5120/tax is unreachable
----------------------------------------
java.net.ConnectException: null
----------------------------------------
java.net.ConnectException: null
----------------------------------------
java.nio.channels.ClosedChannelException: null
----------------------------------------

Our proxy’s [TaxException] encapsulates the [ConnectException] exception thrown by [send], which itself encapsulates the network layer exceptions (Chapter 12). None of them have a message: their [message] property is set to [null], which the [displayError] string template displays as [null].

13.5. A web client for the tax calculation service

A “traditional” web application generates its pages HTML on the server: each time a button is clicked, the form is sent to the server, which returns a new, complete page to the browser. Today, the browser most often queries a web service directly, in JavaScript, and updates only the part of the page that has changed. That’s what we’re going to do. The architecture is as follows:

Image

  • 1: The browser requests the page [/]; the server [WsTax] sends it the static file [index.html];
  • 2: Each time the [Calculate] button is clicked, the JavaScript code on the page sends the [POST /tax] request to the web service and displays the received tax amount on the page without reloading it.

The web service has not been modified: it simply serves one additional file. Spring Boot automatically serves files placed in the [static] folder within the application’s resources ([src/main/resources/static]) as static files: HTML pages, images, scripts, style sheets, etc. The [static/index.html] file is also served for URL and [/]: this is the welcome page. A single server can thus host both a website and a web service. The page [index.html], which is completely independent of the language in which the service is written (it is the same as in the Java course), is as follows:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Calculate your tax</title>
  <style>
    body { font-family: sans-serif; background: #ffffe0; margin: 2em; }
    td { padding: 4px 8px; white-space: nowrap; }
    .error { color: #cc2200; }
    #tax:not(:empty) { background: #99ccff; padding: 2px 8px; font-weight: bold; }
  </style>
</head>
<body>
  <h2>Calculate your tax</h2>
  <form id="form">
    <table>
      <tr>
        <td>Are you married</td>
        <td>
          <label><input type="radio" name="married" value="yes"> Yes</label>
          <label><input type="radio" name="married" value="no" checked> No</label>
        </td>
      </tr>
      <tr>
        <td>Number of children</td>
        <td><input type="text" id="children" size="3"></td>
        <td class="error" id="childrenError"></td>
      </tr>
      <tr>
        <td>Annual salary</td>
        <td><input type="text" id="salary" size="8"></td>
        <td class="error" id="salaryError"></td>
      </tr>
      <tr>
        <td>Tax to pay</td>
        <td><span id="tax"></span></td>
      </tr>
    </table>
    <p class="error" id="message"></p>
    <p>
      <button type="submit">Calculate</button>
      <button type="button" id="clear">Clear</button>
    </p>
  </form>

  <script>
    // the elements of the page
    const form = document.getElementById("form");
    const children = document.getElementById("children");
    const salary = document.getElementById("salary");
    const tax = document.getElementById("tax");
    const childrenError = document.getElementById("childrenError");
    const salaryError = document.getElementById("salaryError");
    const message = document.getElementById("message");

    // an entered integer, or null if the input is not an integer
    function readInteger(text) {
      return /^\s*-?\d+\s*$/.test(text) ? parseInt(text, 10) : null;
    }

    // clears the result and the error messages
    function clearMessages() {
      tax.textContent = "";
      childrenError.textContent = "";
      salaryError.textContent = "";
      message.textContent = "";
    }

    // [Calculate] button: we query the web service, without reloading the page
    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      clearMessages();
      // the request, in the format expected by POST /tax
      const request = {
        married: form.married.value === "yes",
        children: readInteger(children.value),
        salary: readInteger(salary.value)
      };
      try {
        const response = await fetch("tax", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(request)
        });
        const body = await response.json();
        if (response.ok) {
          // 200: {"tax": 4282}
          tax.textContent = `${body.tax} euros`;
        } else {
          // 400: a "Problem Details" document with the errors per parameter
          const errors = body.errors ?? {};
          childrenError.textContent = errors.children?.join(" ") ?? "";
          salaryError.textContent = errors.salary?.join(" ") ?? "";
          message.textContent = body.title;
        }
      } catch (e) {
        // the service is unreachable
        message.textContent = `Error communicating with the service: ${e.message}`;
      }
    });

    // [Clear] button: reset of the form
    document.getElementById("clear").addEventListener("click", () => {
      children.value = "";
      salary.value = "";
      clearMessages();
    });
  </script>
</body>
</html>

Comments:

  • lines 15–44: the form: two radio buttons for marital status, two input fields for the number of children and salary—each followed by a cell where an error message will be displayed if necessary—a field for taxes, a paragraph for a general message, and two buttons;
  • lines 46–108: the page’s code, JavaScript, executed by the browser. This course does not cover JavaScript; however, this code is similar enough to Kotlin to be easily understood;
  • line 58: an input that is not an integer results in [null]; it is up to the service to report the error;
  • Line 70: the click handler for the [Calculate] button (submit form). This is an asynchronous function ([async]): like a Kotlin function (Chapter 9), it does not block the browser while waiting for a response;
  • line 71: we prevent the browser from submitting the form on its own, which would cause a new page to load;
  • lines 74–77: the request, a JavaScript object that has the format expected by [POST /tax];
  • lines 80–83: The browser’s [fetch] function sends the HTTP request: the method [POST], the header [Content-Type], and the body—the request serialized as JSON by [JSON.stringify]. The URL and [tax] are related to the page: the request is sent to the server that provided the page. [await] waits for the response without blocking the browser, similar to calling a [suspend] function in a coroutine;
  • line 85: the JSON body of the response is deserialized into a JavaScript object;
  • lines 86–88: if successful (2xx code), the tax is displayed;
  • Lines 91–94: If an error occurs, the document title “Problem Details” is displayed, along with error messages regarding the number of children and the salary, each next to its corresponding input field. The operator [?.] returns [undefined] if the object to its left does not exist, rather than causing an error; the operator [??] replaces a missing value with the value that follows it. Readers will have recognized the safe call [?.] and the Elvis operator [?:] from Kotlin, which JavaScript adopted in 2020;
  • Lines 103–106: The [Clear] button resets the form locally, without querying the service.

Now that the service is running, let’s request URL and [http://localhost:5120/] using a browser:

Image

After entering a calculation and clicking the [Calculate] button:

Image

If the input is incorrect, the error messages are those from the web service:

Image

These screenshots were taken using the Chromium browser, controlled by a program via the Playwright library: this is how we automate testing of a web interface. We repeated the experiment with the service written in Kotlin: the page, the responses, and therefore the screenshots are identical to those of the service written in Java. That’s the whole point of a web service: its client sees only the documents being exchanged.

Here, the page and the service share the same origin (same protocol, same machine, same port: [http://localhost:5120]). If the page were served by another site, for example [http://localhost:5000], the browser would enforce the same-origin policy: it would only allow the code JavaScript to read the service’s response if the service authorizes it via the header [Access-Control-Allow-Origin]. For a request like ours (POST with a body of JSON), the browser would even request this authorization before sending the request, via a preliminary request using the OPTIONS method (preflight). This is the CORS mechanism (Cross-Origin Resource Sharing), which protects the user from malicious pages that might attempt to use services where the user is logged in without their knowledge. To accept other origins, a Spring controller can be annotated with [@CrossOrigin(origins = ["http://localhost:5000"])] (in Kotlin, an array-type argument is written in square brackets within an annotation); global configuration is also possible (the [addCorsMappings] method of the [WebMvcConfigurer] interface). Clients that are not browsers ([curl], our console clients) are not affected by CORS.

For richer server-generated web interfaces, Spring MVC also offers controllers that return HTML pages built from templates (Thymeleaf, for example), which are beyond the scope of this course. Kotlin also offers its own solution: Kotlin/JS and Kotlin/Wasm compile Kotlin code for the browser, allowing the web client to be written in the same language as the service, while sharing DTO data classes as needed (Kotlin Multiplatform projects).

13.6. A web service is independent of its client’s language

With a SOAP service, the client had to have a tool capable of reading WSDL and generating the proxy, and differences in how standards were interpreted across platforms often caused interoperability issues. With a REST/JSON service, any language that has a HTTP client and a JSON library, that is, all current languages, can query the service without any special tools. We’ve already seen this with [curl], which isn’t written in Kotlin, and with the browser-based JavaScript. Here is a client written for Node.js, the runtime environment for JavaScript outside the browser:

// a JavaScript (Node.js) client of the tax calculation web service
// call: node client-tax.mjs [service url]
const serviceUrl = process.argv[2] ?? "http://localhost:5120/";

// the requests to send: the last one is incorrect
const requests = [
  { married: true, children: 2, salary: 60000 },
  { married: false, children: 0, salary: 60000 },
  { married: true, children: -1, salary: 60000 },
];

for (const request of requests) {
  // POST /tax with the request in JSON
  const response = await fetch(new URL("tax", serviceUrl), {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(request),
  });
  const body = await response.json();
  if (response.ok) {
    console.log(`${JSON.stringify(request)} -> tax = ${body.tax} euros`);
  } else {
    console.log(`${JSON.stringify(request)} -> error ${response.status}: ${JSON.stringify(body.errors)}`);
  }
}
  • line 3: the service’s URL, passed as an argument or set as the default;
  • lines 6–9: three calculation requests, the last of which is incorrect;
  • lines 14–17: the same [fetch] function as in the browser;
  • lines 20–23: display of the tax amount or errors.

Once the service is running:

1
2
3
4
> node client-tax.mjs
{"married":true,"children":2,"salary":60000} -> tax = 4282 euros
{"married":false,"children":0,"salary":60000} -> tax = 11519 euros
{"married":true,"children":-1,"salary":60000} -> error 400: {"children":["The number of children must be a positive or zero integer"]}

In Python, we would similarly use the [requests] or [httpx] module; in C#, the [HttpClient] class; and in Java, the [HttpClient] class that we used from Kotlin. Finally, here is the log for the [WsTax] service throughout all the tests in this chapter ([curl], console client, browser, Node.js client), until it was terminated with Ctrl-C (the course abbreviated the paths on line 2 to [...] and omitted the Gradle messages):

> gradlew :WsTax:bootRun
13:08:56.173 INFO  [main] WsTaxApplicationKt : Starting WsTaxApplicationKt using Java 25.0.4 with PID 23020 (... in .../13-web-services/WsTax)
13:08:56.177 INFO  [main] WsTaxApplicationKt : No active profile set, falling back to 1 default profile: "default"
13:08:57.656 INFO  [main] TomcatWebServer : Tomcat initialized with port 5120 (http)
13:08:57.670 INFO  [main] StandardService : Starting service [Tomcat]
13:08:57.671 INFO  [main] StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
13:08:57.703 INFO  [main] WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 1416 ms
13:08:57.968 INFO  [main] WelcomePageHandlerMapping : Adding welcome page: class path resource [static/index.html]
13:08:58.281 INFO  [main] TomcatWebServer : Tomcat started on port 5120 (http) with context path '/'
13:08:58.288 INFO  [main] WsTaxApplicationKt : Started WsTaxApplicationKt in 2.599 seconds (process running for 2.965)
13:08:58.385 INFO  [http-nio-5120-exec-1] [/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
13:08:58.385 INFO  [http-nio-5120-exec-1] DispatcherServlet : Initializing Servlet 'dispatcherServlet'
13:08:58.387 INFO  [http-nio-5120-exec-1] DispatcherServlet : Completed initialization in 2 ms
13:08:58.459 INFO  [http-nio-5120-exec-1] TaxController : Tax(married=true, children=2, salary=60000) = 4282
13:08:58.502 WARN  [http-nio-5120-exec-3] TaxController : Incorrect request: married, children, salary
13:08:58.578 INFO  [http-nio-5120-exec-4] TaxController : Tax(married=true, children=2, salary=60000) = 4282
13:09:01.741 INFO  [http-nio-5120-exec-5] TaxController : Tax(married=true, children=2, salary=60000) = 4282
13:09:01.827 INFO  [http-nio-5120-exec-6] TaxController : Tax(married=true, children=3, salary=60000) = 2959
13:09:01.834 INFO  [http-nio-5120-exec-6] TaxController : Tax(married=false, children=0, salary=60000) = 11519
13:09:04.591 INFO  [http-nio-5120-exec-9] TaxController : Tax(married=true, children=2, salary=60000) = 4282
13:09:04.704 WARN  [http-nio-5120-exec-10] TaxController : Incorrect request: children, salary
13:09:06.979 INFO  [http-nio-5120-exec-1] TaxController : Tax(married=true, children=2, salary=60000) = 4282
13:09:06.993 INFO  [http-nio-5120-exec-2] TaxController : Tax(married=false, children=0, salary=60000) = 11519
13:09:06.999 WARN  [http-nio-5120-exec-3] TaxController : Incorrect request: children
13:09:09.088 INFO  [SpringApplicationShutdownHook] GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
13:09:09.096 INFO  [tomcat-shutdown] GracefulShutdown : Graceful shutdown complete
  • line 8: Spring Boot found the home page [static/index.html];
  • Lines 14–24: Messages from our controller: The lines starting with [INFO] log the calculations, while the lines starting with [WARN] log invalid requests, along with the names of the parameters involved. Lines 14–16 come from [curl], lines 17–19 from the console client, lines 20–21 from the browser, and lines 22–24 from the Node.js client. The log category, [TaxController], is indeed the name of the class, not that of its companion object;
  • the thread names ([http-nio-5120-exec-1] through [exec-10]) show that requests are distributed among the threads in the Tomcat pool; successive requests from the same client are not necessarily processed by the same thread. The last two requests from the console client (lines 18–19) were handled by the same thread: the client reused the same connection (HTTP/1.1 keeps the connection open between two requests), and Tomcat assigns the next request from a connection to the first available thread.

13.7. Additional Information

13.7.1. Response Types and Error Handling

The [WsOperations] service returns [ResponseEntity<*>] objects: based on the signatures of its functions, it is unclear what responses they might produce, and the compiler does not perform any checks. The [WsTax] service returns the specific type of its response upon success, [TaxResponse], and signals errors via exceptions, which are converted into responses by a [@ExceptionHandler] handler. This is the most common approach with Spring: the function code handles only the normal case, and the tools (the OpenAPI document generator in particular) know the JSON response schema without being explicitly told. A unit test can also directly call a controller method and examine its result, without going through HTTP. [ResponseEntity<T>], with a specific type, remains useful when you need to select the status code or add headers, for example, for a 201 (Created) response accompanied by its [Location] header: [ResponseEntity.created(uri).body(client)].

A [@ExceptionHandler] handler placed in a controller handles only that controller’s exceptions. To handle exceptions from all controllers in an application, place the handlers in a class annotated with [@RestControllerAdvice]. Spring also provides the [ErrorResponseException] class, an exception that directly carries a [ProblemDetail] and produces the corresponding response without a handler, and the class *[ResponseEntityExceptionHandler]*, which can be extended to customize responses to errors detected by Spring MVC itself (unreadable JSON document, rejected document type, etc.).

In Kotlin, the result of an operation can also be represented by a sealed class (Chapter 4): [sealed interface Result], with one data class for success and another for failure. A comprehensive [when] expression then transforms each case into a HTTP response, and the compiler verifies that no cases are omitted. This is an alternative to exceptions, favored by Kotlin developers, that keeps the possible failures of an operation visible in the types.

13.7.2. A Common Prefix for Routes

When multiple routes share a prefix, it is specified once and for all using the [@RequestMapping] annotation placed on the controller class. For example, with [@RequestMapping("/api/operations")] on the [OperationsController] class, the routes become [GET /api/operations/add?a=1&b=2] and [POST /api/operations/calculation], without any further changes. This is also a simple way to manage service versions ([/api/v1/...], [/api/v2/...]).

13.7.3. Interceptors and Validation

Our services validate their own parameters. Spring provides two mechanisms for moving this code out of the controllers:

  • interceptors: a class that implements the [HandlerInterceptor] interface, registered by the [addInterceptors] method of [WebMvcConfigurer], is executed before and after controller methods; it can examine the request and respond on its own, without calling the controller. They are used for logging, measuring response times, authentication, and more. Servlet filters ([jakarta.servlet.Filter]) serve the same purpose at a lower level;
  • validation: with the [spring-boot-starter-validation] starter (which provides Hibernate Validator, the reference implementation of the Jakarta Validation API), the properties of a DTO can be annotated with constraints: [data class TaxRequest(@NotNull val married: Boolean?, @NotNull @Min(0) val children: Int?, @NotNull @Min(0) val salary: Int?)]. With Kotlin 2.4, an annotation placed on a property declared in the primary constructor applies to both the constructor parameter and the property field, which the validator reads (we verified this through reflection on a custom annotation). In earlier versions of Kotlin, the annotation applied only to the parameter: you had to specify the target of the annotation, [@field:NotNull], a form that is still encountered in many examples. A [@Valid @RequestBody request: TaxRequest] parameter is then validated before the function is called, and an invalid request is rejected with a 400 “Problem Details” response, such as the ones we constructed.

13.7.4. Hosting

Our services are run by [gradlew bootRun], which is suitable for development. In production:

  • the application is first packaged by [gradlew bootJar]: the Spring Boot plugin generates a single “executable” [.jar] file, which contains the application, its dependencies (including the Kotlin standard library), and the Tomcat server. Simply copy it to the server and run it using [java -jar WsTax-9.0.jar]; the configuration can be modified using command-line arguments ([--server.port=80]), environment variables, or a [application.properties] file placed next to the [.jar] file. For JVM, a Kotlin application is just like any other Java application;
  • Tomcat can be exposed directly to the Internet, but it is often placed behind a reverse proxy (nginx or Apache on Linux, IIS on Windows) that receives HTTPS requests, manages certificates, and distributes the load across multiple instances of the application;
  • The application can also be deployed as a container image (Docker), which the Spring Boot plugin can generate without a [Dockerfile] file: [gradlew bootBuildImage]. The image then runs on any container platform, whether on your own server or with a hosting provider;
  • HTTPS is essential: the Spring Boot properties [server.ssl.*] configure the Tomcat server certificate when the server is exposed directly.

13.8. Conclusion

This chapter introduced the REST/JSON web services, the successors to the SOAP web services, written in Kotlin using Spring Boot and Spring MVC. We have seen:

  • that a web service maps URL and HTTP methods to Kotlin functions using [@RestController] annotations, [@GetMapping] and [@PostMapping], and that the parameters for these functions are derived from the route ([@PathVariable]), the query string ([@RequestParam]), the body (JSON) ([@RequestBody]), or the Spring container (injection via the primary constructor);
  • that Spring supports Kotlin: [runApplication], nullable parameters that become optional, primary constructors and their default values ([@ConfigurationProperties]), provided that the [kotlin-reflect] library is supplied and the [plugin.spring] plugin is applied;
  • the results are sent in JSON format by Jackson, based on data classes, and errors are reported via a status code and a “Problem Details” document ([ProblemDetail], [ResponseEntity], [@ExceptionHandler]);
  • that a Kotlin client uses [java.net.http.HttpClient] and Jackson, and that a client written in any other language can do the same;
  • that the layered architecture delivered as promised: Version 9 of the tax calculation reuses, without modification, the server-side layers [dao] and [business] from Version 5, as well as its client-side layer [ui]; All it took was writing the web service (about 100 lines) and a new implementation of the [ITaxBusiness] interface (about 50 lines).