Kotlin and Spring Boot: Hypermedia Driven Web Service

Learn about HATEOAS, build a state machine to model an article review workflow, use Spring-HATEOAS and see how hypermedia clients adapt. By Prashant Barahi.

Leave a rating/review
Download materials
Save for later
Share

In software engineering, much effort goes toward building a loosely coupled system. A loosely coupled system allows you to reduce the effect of changes made in one part of the system on other parts.

You can decouple the frontend application from the server by making the latter send extra information in the response. This information tells the client what actions are possible on a given resource. Clients can then use this information to drive the application from one state to another. This is HATEOAS.

HATEOAS stands for Hypermedia as the Engine of Application State. Although it sounds overwhelming, the idea is rather simple. It’s the way of designing a REST application so the server embeds hypermedia links as metadata in the response. The server crafts these links using the current application state before sending them to the client. The client can then use those hypermedia links to drive the application from the current state to another.

This article and every other one you’ve read on our website went through a review workflow. In this tutorial, you’ll build a hypermedia-based server by modeling a similar review workflow that’s practiced by our tutorial team.

In the process, you’ll:

  • Learn the concepts of HATEOAS.
  • Build a state machine to model the review workflow.
  • Use Spring-HATEOAS to build a hypermedia-powered Spring Boot server.
  • Handle change requirements and see how hypermedia-based clients can adapt.
Note: This tutorial assumes you’re familiar with Spring Boot and Kotlin. If you’re new to Spring Boot, refer to Kotlin and Spring Boot: Getting Started tutorial.

Getting Started

Click Download Materials at the top or bottom of the tutorial to download the starter project. Fire up the IntelliJ IDEA and select Open…. Then, navigate to and open the starter project.

First, make sure you’ve installed JDK 11 or higher. Open Application.kt and run the project.


Main application

Or you can also open a Terminal and execute:

./gradlew bootRun

You’ll find the server running on port 8080.

The starter project also contains a React application in the /ui folder. To build and run it, make sure you have Node.js v16.15.1 and npm v8.11.0 (or greater) installed by executing the following commands:

node -v

And:

npm -v

If you don’t have them, go to the official site to download and install them.

Then, build and run the React application by executing this:

cd ui
npm ci
npm run start

You should find it running on port 3000.

Open your browser and go to http://localhost:3000. By default, it makes requests to localhost:8080 (where the Spring Boot server is running), so you’ll see a list of articles like this:

Articles home page

The app isn’t fully usable now, so minimize it.

Note: If you aren’t familiar with React, don’t worry: You won’t need to make any changes to the React app. This tutorial will explain its workings later.

Now, look at the starter project. It contains the following files:

  • ArticleSeedInitializer.kt seeds the empty database with initial data.
  • StateMachineConfigurer.kt provides APIs you use to configure the states and the transitions of a state machine. Then, you supply it to a StateMachineFactory.
  • ArticleStateMachineBeanConfig.kt is where you configure ArticleStateMachineFactory.
  • StateMachineFactoryProvider.kt provides methods to retrieve a StateMachineFactory bean you’ve configured.
  • FakeNetworkDelayFilter.kt adds a random delay to every request.

What Is a State Machine?

A state machine is a mathematical model of computation. It has a finite number of states and can be in any one of them at a time.

It starts from an initial state and responds to events by transitioning from its current state to the next if the current state allows it. On reaching the next state, it becomes ready to accept events again.

In this project, you use a state machine to drive the review workflow. Before you dive into the code, you’ll first learn about the business problem you’ll solve.

Three Level Review Workflow

Each article goes through a review process before it gets published. For now, consider there are just three steps. Call it the Three Level Review Workflow (3LRW)!

The author starts the article. They pick a topic from the topic pool and write the article. Once the draft is ready for review, it goes to the technical editor (TE).

Technical editors proofread the article and perform an expert-level review of the content. They can either approve the article and send it to the final pass editor (FPE) or reject the article with feedback if they feel it needs further polish.

Final pass editors stand as the last line of defense. Once they approve the article, they publish it, making it available to readers. After this, any updates to the article are not allowed. However, if the article fails the quality check, the final pass editor can send it back to the author and the process restarts.

The 3LRW state diagram is:

Three Level Review Workflow

Next, you’ll model this workflow using a state machine.

Building the 3LRW State Machine

The starter project already has an outline of a state machine implemented.

ArticleState defines all the states a state machine can go through in 3LRW, namely DRAFT, AUTHOR_SUBMITTED,TE_APPROVED and PUBLISHED.

Similarly, AUTHOR_SUBMIT, TE_APPROVE, TE_REJECT, FPE_APPROVE and FPE_REJECT are the events the 3LRW supports. They’re defined in the ArticleEvent.

Refer to the state diagram to understand how these events and the states relate to each other.

To build a StateMachine with a 3LRW configuration, you need a StateMachineFactory. Therefore, you also need to provide a configuration to the StateMachineFactory to comply with 3LRW. Open ArticleStateMachineBeanConfig.kt and go to providesThreeLevelReviewStateMachineFactory():

fun providesThreeLevelReviewStateMachineFactory(): ArticleStateMachineFactory {
  // ...
  .withTransitions {
    // Author
    defineTransition(start = DRAFT, trigger = AUTHOR_SUBMIT, end = AUTHOR_SUBMITTED)

    // TODO: define other transitions
  }
  // ...

This is an incomplete implementation of a 3LRW state machine. So replace all the block content with the following snippet:

// Author
defineTransition(start = DRAFT, trigger = AUTHOR_SUBMIT, end = AUTHOR_SUBMITTED)

// TE
defineTransition(start = AUTHOR_SUBMITTED, trigger = TE_APPROVE, end = TE_APPROVED)
defineTransition(start = AUTHOR_SUBMITTED, trigger = TE_REJECT, end = DRAFT)

// FPE
defineTransition(start = TE_APPROVED, trigger = FPE_APPROVE, end = PUBLISHED)
defineTransition(start = TE_APPROVED, trigger = FPE_REJECT, end = DRAFT)

Here, you’ve defined the event (i.e., trigger) that — when consumed — causes the state machine to transition from the start state to the end state.

Notice the return type of this function is an ArticleStateMachineFactory. This is just an alias for StateMachineFactory<ArticleState, ArticleEvent>.

Handling Events

To handle the events, open ArticleService.kt and look at the following method:

fun handleEvent(articleId: Long, event: ArticleEvent) {
  val article = repository.findById(articleId).orElseThrow()
  // 1
  val stateMachineFactory = stateMachineFactoryProvider
    .getDefaultStateMachineFactory<ArticleState, ArticleEvent>()
  // 2
  val stateMachine = stateMachineFactory
    .buildFromHistory(article.getPastEvents())

  // 3
  stateMachine.setOnTransitionListener(object :
    OnStateTransitionListener<ArticleState, ArticleEvent> {
    override fun onTransition(
      prevState: ArticleState,
      event: ArticleEvent,
      nextState: ArticleState
    ) {
      article.state = nextState
      article.consumeEvent(event)
    }
  })
  // 4
  val eventResult = stateMachine.sendEvent(event)
  if (!eventResult) {
    throw StaleStateException("Event ${event.alias} could not be accepted.")
  }
  repository.save(article)
}

Here’s what’s going on:

  1. You get the default StateMachineFactory, which is the one whose transitions you reconfigured before.
  2. On this factory, you could call create() to get a fresh instance of the 3LRW StateMachine. But in this case, you need to restore a StateMachine to the article’s current state by calling buildFromHistory() and passing in the list of events it must consume to reach that state. This is called Event Sourcing.
  3. Whenever a transition happens, you update the state of the article and store the corresponding event.
  4. You call sendEvent() to initiate a state transition. All the events that have been consumed successfully get persisted to the database (and are used for Event Sourcing).

Take a few moments to explore this setup. In the next section, you’ll learn about HATEOAS.

Understanding Hypermedia Responses

Consider a hypothetical endpoint that serves articles. Sending a GET request to /articles/1 should return an article with ID of 1.

{
  "id": 1,
  "title": "Heading 1",
  "body": "Lorem ipsum.",
  "status": "DRAFT"
}

It’s difficult to know what actions are allowed on a resource, just from the response above. However, a hypermedia response includes actions that are currently available as well. The server crafts them using the current snapshot of the resource.

So what does this “hypermedia response” look like? A GET /articles/1 but on a hypermedia-based server returns this:

{
  "id": 1,
  "title": "Heading 1",
  "body": "Lorem ipsum.",
  "status": "DRAFT",
  "_links": {
    "self": {
      "href": "http://localhost:8080/articles/1"
    },
    "update": {
      "href": "http://localhost:8080/articles/1"
    }
  }
}

Through this response, a client can determine what operations are allowed on a resource. The presence of _links.update implies it’s possible to update that resource and use that information to, for example, display an Update button to the users.

Next, you’ll learn to build these hypermedia links.

Supporting Hypermedia in Spring Boot

The sample project uses the Spring-HATEOAS library to construct the hypermedia response. You’ll notice the following dependency in the build.gradle.kts:

implementation("org.springframework.boot:spring-boot-starter-hateoas")

This library provides static methods under WebMvcLinkBuilder to introspect methods. It also has classes like RepresentationModel, CollectionModel and so on to model the hypermedia response.

You enable the support for hypermedia by annotating the Application using @EnableHypermediaSupport (the project is already annotated with it):

import org.springframework.hateoas.config.EnableHypermediaSupport

@SpringBootApplication
@EnableHypermediaSupport(type = [
  EnableHypermediaSupport.HypermediaType.HAL,
  EnableHypermediaSupport.HypermediaType.HAL_FORMS,
])
class Application

Later, you’ll learn what these HypermediaType.HAL and HypermediaType.HAL_FORMS are. For now, go to ArticleResource.kt, where you’ll find ArticleResource inheriting from RepresentationModel. ArticleController exposes the /articles endpoint that returns an ArticleResource or a variation of it.

Finally, go to the most important class: ArticleAssembler.kt. Here, you craft the hypermedia response the client receives. Its outline looks like this:


@Component
class ArticleAssembler 
@Autowired constructor(
  private val stateMachineFactoryProvider: StateMachineFactoryProvider
) : RepresentationModelAssembler<ArticleEntity, ArticleResource> {
  // ...
}

The ArticleAssembler translates the database entity ArticleEntity to the ArticleResource.

Build and run the server and execute this curl command:

curl http://localhost:8080/articles -H "Accept:application/hal+json" 

And fetch the article with ID 1 using:

curl http://localhost:8080/articles/1 -H "Accept:application/hal+json" 

The responses you get aren’t any different than the non-hypermedia-based responses. Next, you’ll learn what this application/hal+json is!

Media Types

In the curl commands you executed before, you sent a strange looking Accept header: application/hal+json. This is the registered media type identifier for JSON HAL (JSON Hypertext Application Language). HAL is the simplest and most widely adopted hypermedia media type.

Another media type called HAL-FORMS provides information on HTTP methods, message content-type and request parameters to use when making a request to the server. Its associated media type identifier is application/prs.hal-forms+json.

Other media types are available for hypermedia as well, and the “On choosing a hypermedia type for your API” blog does a great job of comparing them.

The React client is built with support for a HAL-FORMS-based response. Thus, it sends application/prs.hal-forms+json as an Accept header on every request.

Note: This project uses HAL-FORMS because of its structure that can be used to specify actions on a resource, and not for its runtime forms capability. Another alternative for this would be Siren.

Now that you’re familiar with media types and identifiers, you’ll learn to enrich the hypermedia response in the upcoming sections.

Building the “Self” Link

The _links field is a reserved property. It contains one or more link objects. One of them is the self link, with which a client can get the full representation of the resource.

Adding a self link is straightforward. Go to the ArticleAssembler.kt. There, you’ll find buildSelfLink():

fun buildSelfLink(entity: ArticleEntity): Link {
  return linkTo(
    methodOn(ArticleController::class.java)
      .getById(entity.id!!)
  )
  .withSelfRel()
}

Both linkTo() and methodOn() are static methods from org.springframework.hateoas.server.mvc.WebMvcLinkBuilder. The getById() is a method in ArticleController, which is used in building a self link.

Finally, inside toModel(), call this method by replacing the // TODO: Add Link here with the snippet below:

val resourceLink = buildSelfLink(entity)
resource.add(resourceLink)

Again, build and run the project and execute the following command:

curl http://localhost:8080/articles/1 -H "Accept:application/hal+json" 

It returns:

{
  "id": 1,
  "state": "DRAFT",
  "title": "Getting Started with Cucumber",
  "body": "[...]",
  "updatedDate": "2022-05-22T23:43:33.990617",
  "createdDate": "2022-05-22T23:43:33.990576",
  "reviewType": "FOUR_LEVEL_WORKFLOW",
  "_links": {
    "self": {
      "href": "http://localhost:8080/articles/1"
    }
  }
}

As you can see, it now includes the _links.self field.

Before you continue enriching the response, you’ll first learn about affordances. To the next section!

Affordance

Hypermedias are resources and affordances. An article is a resource, and the actions you can perform on that resource are its affordances.

As mentioned in the “hypermedia affordances” blog, affordances are what the resource “offers” and how they offer it.

So updating an article by sending a PUT request to /articles/1 can be one of its affordances. Moreover, they also need to be context-sensitive. If you aren’t allowed to edit or delete an article that’s already published, the affordances that allow for those two actions shouldn’t be available in the response.

Affordances also convey what “actions” are available, and clients can use that information to drive the application state forward. Hence, unlike resources, affordances are represented as verbs, not nouns.

To see these “actions” in action, open ArticleController.kt and check the handleAction():

@PostMapping("/{articleId}/{action}")
fun handleAction(
  @PathVariable articleId: Long,
  @PathVariable action: String
): RepresentationModel<ArticleResource> {
  val event =
    eventMapper.getArticleEvent(action) ?: throw IllegalArgumentException("$action is invalid")
  service.handleEvent(articleId, event)
  return articleAssembler.toModel(service.findById(articleId))
}

ArticleEventMapper translates the “verbified” alias to the corresponding ArticleEvent that the state machine consumes in the handleEvent(). If that event is supported, it transitions into the next state while also updating the article’s state.

Building the “update” Link

Users shouldn’t be allowed to update a published article. Hence, the update affordance is context-sensitive.

To implement this, go to ArticleAssembler.kt and add the following method:

private fun Link.addUpdateAffordance(entity: ArticleEntity): Link {
  if (entity.isPublished()) return this                          // 1
  val configurableAffordance = Affordances.of(this)
  return configurableAffordance.afford(HttpMethod.PUT)            // 2
    .withName(UPDATE)
    .withTarget(
      linkTo(
        methodOn(ArticleController::class.java)
          .updateArticle(entity.id!!, null)                       // 3
      ).withRel(UPDATE)
    )
    .withInput(ArticleRequest::class.java)
    .toLink()
}

And the following imports:

import com.yourcompany.articlereviewworkflow.models.ArticleRequest
import org.springframework.hateoas.mediatype.Affordances

Here:

  1. This if check prevents the update affordance from getting added to the final hypermedia response if the entity‘s state is PUBLISHED. For simplicity, this project doesn’t distinguish between users or their roles so it doesn’t implement any Role-based Access Control. If it was supported, these if checks are exactly how you would implement context-sensitive affordances.
  2. Build the affordance by specifying its HTTP method and its name.
  3. Supply the handler updateArticle() to the static methods for introspection.

Now, invoke it by chaining it to buildSelfLink() in the toModel():

val resourceLink = buildSelfLink(entity)
  .addUpdateAffordance(entity)

Finally, build and run the project and execute the following command:

curl http://localhost:8080/articles/1 -H "Accept:application/prs.hal-forms+json"

And you’ll get the following response:

{
  "id": 1,
  "state": "DRAFT",
  "title": "[...]",
  "body": "[...]",
  "updatedDate": "2022-05-22T23:43:33.990617",
  "createdDate": "2022-05-22T23:43:33.990576",
  "reviewType": "THREE_LEVEL_WORKFLOW",
  "_links": {
    "self": {
      "href": "http://localhost:8080/articles/1"
    }
  },
  "_templates": {
    "default": {
      "method": "PUT",
      "properties": [
        {
          "name": "body",
          "readOnly": true,
          "type": "text"
        },
        {
          "name": "title",
          "readOnly": true,
          "type": "text"
        }
      ]
    }
  }
}
Note: Remember to use application/prs.hal-form+json as an Accept header, because this is what the React app uses to get the HAL-FORMS-based response.

Because the update affordance shares the URL with _links.self.href, its URL isn’t present in the response.

The PUT method you’ve added – appeared as “default” link. This is a weak point of HAL-FORMS specification.

So to offset the links you added previously (and those you’ll add later), call the addDefaultAffordance() right after the buildSelfLink():

val resourceLink = buildSelfLink(entity)
  .addDefaultAffordance()
  .addUpdateAffordance(entity)

This hack adds a “dummy” TRACE method as the first entry in the _templates and hence is named as “default”.

Restart the server and execute the previous command again. You should see the following response:

{
  "id": 1,
  "state": "DRAFT",
  "title": "[...]",
  "body": "[...]",
  "updatedDate": "2022-05-22T23:43:33.990617",
  "createdDate": "2022-05-22T23:43:33.990576",
  "reviewType": "THREE_LEVEL_WORKFLOW",
  "_links": {
    "self": {
      "href": "http://localhost:8080/articles/1"
    }
  },
  "_templates": {
    "default": {
      "method": "TRACE",
      "properties": []
    },
    "update": {
      "method": "PUT",
      "properties": [
        {
          "name": "body",
          "readOnly": true,
          "type": "text"
        },
        {
          "name": "title",
          "readOnly": true,
          "type": "text"
        }
      ]
    }
  }
}

Building the Tasks Link

To let the client know what workflow-related actions they can perform next, add the following methods in the ArticleAssembler.kt:

private fun getAvailableActions(entity: ArticleEntity): List<ArticleEvent> {
  if (entity.isPublished()) return emptyList()

  val stateMachineFactory = stateMachineFactoryProvider
    .getDefaultStateMachineFactory<ArticleState, ArticleEvent>()
  val stateMachine = stateMachineFactory
    .buildFromHistory(entity.getPastEvents())           // 1

  val nextEvents = stateMachine.getNextTransitions()    // 2
  return nextEvents.toList()
}

private fun Link.addActionsAffordances(entity: ArticleEntity): Link {
  val buildActionTargetFn: (ArticleEvent) -> Link = { event ->
    linkTo(
      methodOn(ArticleController::class.java)
        .handleAction(entity.id!!, event.alias)
    ).withRel(ACTIONS)
  }

  val events = getAvailableActions(entity)
  if (events.isEmpty()) return this

  // 3
  val configurableAffordance = Affordances.of(this)
    .afford(HttpMethod.POST)
    .withName(events.first().name)
    .withTarget(buildActionTargetFn(events.first()))

  return events.subList(1, events.size)
    .fold(configurableAffordance) { acc, articleEvent ->
      acc.andAfford(HttpMethod.POST)
        .withName(articleEvent.name)
        .withTarget(buildActionTargetFn(articleEvent))
    }.toLink()
}

Ensure you import the following:

import com.yourcompany.articlereviewworkflow.statemachine.articles.ArticleEvent
import com.yourcompany.articlereviewworkflow.statemachine.articles.ArticleState

Here:

  1. getPastEvents() returns all events the state machine had consumed. buildFromHistory() restores a state machine to its current state by replaying all these events.
  2. getNextTransitions() returns a list of events the state machine can consume at its current state. In 3LRW, a state machine at AUTHOR_SUBMITTED state supports TE_APPROVE and TE_REJECT events (see the 3LRW state diagram).
  3. You build the affordance for each action by passing those event values to the handleAction() of ArticleController.

Finally, invoke this method in the toModel() by hooking it to the buildSelfLink():

val resourceLink = buildSelfLink(entity)
  .addDefaultAffordance()
  .addUpdateAffordance(entity)
  .addActionsAffordances(entity)

Restart the server and execute the following command:

curl http://localhost:8080/articles/1 -H "Accept:application/prs.hal-forms+json"

And you get the following response:

{
  "id": 1,
  "state": "DRAFT",
  "title": "Getting Started with Cucumber",
  "body": "[...]",
  "updatedDate": "2022-05-22T23:43:33.990617",
  "createdDate": "2022-05-22T23:43:33.990576",
  "reviewType": "THREE_LEVEL_WORKFLOW",
  "_links": {
    "self": {
      "href": "http://localhost:8080/articles/1"
    }
  },
  "_templates": {
    "default": {
      "method": "TRACE",
      "properties": []
    },
    "update": {
      "method": "PUT",
      "properties": [
        {
          "name": "body",
          "readOnly": true,
          "type": "text"
        },
        {
          "name": "title",
          "readOnly": true,
          "type": "text"
        }
      ]
    },
    "AUTHOR_SUBMIT": {
      "method": "POST",
      "properties": [],
      "target": "http://localhost:8080/articles/1/authorSubmit"
    }
  }
}

This time, the AUTHOR_SUBMIT included the target because it’s different than the _links.self.href.

Great! With 3LRW implemented, you can fully use the React app.

Understanding the Hypermedia Client

So how does the React client make sense of the HAL-FORMS hypermedia response?

It checks for the presence of the _templates.update field to determine whether the resource allows updates. If it does, it enables the input field and shows the Save button in the UI.

Clicking the Save button makes a request using the HTTP verb in _templates.update.method. It creates a request body using the input fields from the UI. Remember that the absence of a _templates.update.target field implies it has exactly the same value as _links.self.href, so the client uses the _links.self.href as the URL to make the request.

The curl request for the update affordance is:

curl -X PUT http://localhost:8080/articles/1 -d '{"title":"Title from UI", "body":"Body from UI"}' -H "Accept:application/prs.hal-forms+json" -H "Content-Type:application/json"

As for the action buttons, the UI considers all the links with _templates.*.method equal to POST and with target like http://localhost:8080/articles/{articleId}/* as the workflow actions and renders the button accordingly. Because the AUTHOR_SUBMIT link meets these criteria, it’s treated as a workflow action. This is why you see the AUTHOR SUBMIT button in the UI. The text in the workflow button is derived by omitting the underscores in the link name.

Use Chrome Developer Tools to see what response the browser gets from the hypermedia-based server at each stage.

Network Request

You might think: “All this effort, but for what?”. Well, you’ll have your question answered in the next section!

A Non-Hypermedia-Based Client

A typical response for an article resource from a non-hypermedia server looks like this:

{
  "id": 1,
  "title": "Heading 1",
  "body": "Lorem ipsum.",
  "status": "DRAFT"
}

Now, to show the details of an article using the response above, a typical Kotlin client-side code (in a hypothetical UI framework) might look like:

fun buildUI(article: Article) {
  titleTextView.text = article.title
  descriptionTextView.text = article.body
  if (article.state == ArticleState.AUTHOR_SUBMITTED) {
      buildSaveButton(article.id).show()
      buildTEApproveButton(article.id).show()
      buildTERejectButton(article.id).show()
  }
  // and other branches
  // ...
}

private fun buildTEApproveButton(id: Long): Button {
  val btn = Button()	 	 
  btn.text = "TE APPROVE"	 	 
  btn.setOnClickListener {	 	 
     // makes POST request to /articles/{id}/teApprove
  }	 	 
  return btn
}

private fun buildTERejectButton(id: Long): Button {
  // makes POST request to /articles/{id}/teReject
}

// other build Button methods
// ...

So what’s wrong with this? Well, for starters, the client is coupled to the workflow. The client needs to know all the states of the article and the possible transitions in each state and also how to initiate these transitions (by making a POST request to /articles/{id}/* endpoint). Also, any change in workflow breaks the client, and you’ll need to update and then redeploy the client.

In the next section, you’ll see how the hypermedia server and client handle the inevitable changes in business requirements!

Four Level Review Workflow

The tutorial team is growing! With team members from around the world, the management team decided to improve the content’s quality by introducing Editors into 3LRW.

After technical editing, the article goes to the editor. Editors ensure the writing is clean, concise and grammatically correct and follows guidelines before they send it to the final pass editor. This successor to 3LRW is called the Four Level Review Workflow (4LRW):

Four Level Review Workflow state diagram

This is a breaking change for non-hypermedia clients. But can this be implemented without making any changes to the client?

Building the 4LRW State Machine

Before starting with the code, ensure the React app is running. Go to localhost:3000 in your browser and ensure 3LRW is working.

First, you need to define the additional states and events the 4LRW requires. Go to ArticleState.kt and add EDITOR_DONE state to the enum class. And in the ArticleEvent.kt, add EDITOR_APPROVE("editorApprove").

In the ArticleStateMachineBeanConfig, define a new ArticleStateMachineFactory for 4LRW by adding the following method:

@Primary
@Bean(FOUR_LEVEL_REVIEW_STATE_MACHINE)
fun providesFourLevelReviewStateMachineFactory(): ArticleStateMachineFactory {
  val configuration = StateMachineConfigurer.StateBuilder<ArticleState, ArticleEvent>()
    .withStartState(DRAFT)
    .withEndState(PUBLISHED)
    .withStates(
      DRAFT, 
      AUTHOR_SUBMITTED,
      TE_APPROVED,
      EDITOR_DONE,
      PUBLISHED
    )
    .and()
    .withTransitions {

      // Author
      defineTransition(start = DRAFT, trigger = AUTHOR_SUBMIT, end = AUTHOR_SUBMITTED)

      // TE
      defineTransition(start = AUTHOR_SUBMITTED, trigger = TE_APPROVE, end = TE_APPROVED)
      defineTransition(start = AUTHOR_SUBMITTED, trigger = TE_REJECT, end = DRAFT)

      // Editor
      defineTransition(start = TE_APPROVED, trigger = EDITOR_APPROVE, end = EDITOR_DONE)

      // FPE
      defineTransition(start = EDITOR_DONE, trigger = FPE_APPROVE, end = PUBLISHED)
      defineTransition(start = EDITOR_DONE, trigger = FPE_REJECT, end = DRAFT)
    }
  return StateMachineFactory(ReviewType.FOUR_LEVEL_WORKFLOW, configuration)
}

Outside the class, add the following line:

const val FOUR_LEVEL_REVIEW_STATE_MACHINE = "FourLevelReviewStateMachineFactory"

And finally, add FOUR_LEVEL_WORKFLOW in the ReviewType:

FOUR_LEVEL_WORKFLOW {
  override val key = this.name
}

The most important part is annotating the providesFourLevelReviewStateMachineFactory() with @Primary and removing it from the providesThreeLevelReviewStateMachineFactory(). After this, the outline of the ArticleStateMachineBeanConfig becomes:

const val THREE_LEVEL_REVIEW_STATE_MACHINE = "ThreeLevelReviewStateMachineFactory"
const val FOUR_LEVEL_REVIEW_STATE_MACHINE = "FourLevelReviewStateMachineFactory"

@Configuration
class ArticleStateMachineBeanConfig {
  @Bean(THREE_LEVEL_REVIEW_STATE_MACHINE)
  fun providesThreeLevelReviewStateMachineFactory(): ArticleStateMachineFactory {
    // ...
  }

  @Primary 
  @Bean(FOUR_LEVEL_REVIEW_STATE_MACHINE)
  fun providesFourLevelReviewStateMachineFactory(): ArticleStateMachineFactory {
    // ...
  }
}

The StateMachineFactoryProvider‘s getDefaultStateMachineFactory() will now return the 4LRW StateMachineFactory because you annotated it with @Primary.

Check ArticleService‘s save():

fun save(title: String, body: String): ArticleEntity {
  val stateMachineFactory = stateMachineFactoryProvider.getDefaultStateMachineFactory()
  return ArticleEntity
    .create(
      title = title,
      body = body,
      reviewType = stateMachineFactory.identifier as ReviewType
    )
    .let(repository::save)
}

Every newly created article will use 4LRW, because getDefaultStateMachineFactory() will return the 4LRW factory.

Also, look at the handleEvent():

fun handleEvent(articleId: Long, event: ArticleEvent) {
  // ...
  val stateMachineFactory = stateMachineFactoryProvider
    .getDefaultStateMachineFactory<ArticleState, ArticleEvent>()
  val stateMachine = stateMachineFactory
    .buildFromHistory(article.getPastEvents())
  // ...

As you might have guessed, this code won’t work for existing 3LRW articles because the default factory now follows 4LRW.

Because there is more than one StateMachineFactory now — one for 3LRW and one for 4LRW — you need to choose which StateMachineFactory to use. Do this by passing the review type to StateMachineFactoryProvider‘s getStateMachineFactory().

Replace the code in handleEvent(), where it gets the default factory, to the following:

fun handleEvent(articleId: Long, event: ArticleEvent) {
  // ...
  val stateMachineFactory = stateMachineFactoryProvider
    .getStateMachineFactory<ArticleState, ArticleEvent>(article.reviewType)
  // ...

Also, open ArticleAssembler.kt and replace the code in getAvailableActions() where it gets the default factory to the following:

private fun getAvailableActions(entity: ArticleEntity): List<ArticleEvent> {
  // ...
  val stateMachineFactory = stateMachineFactoryProvider
    .getStateMachineFactory<ArticleState, ArticleEvent>(entity.reviewType)
  // ...

This means the code now is retro-compatible, because getStateMachineFactory<ArticleState, ArticleEvent>(article.reviewType) will return the 3LRW factory for those articles previously saved with the THREE_LEVEL_WORKFLOW review type.

Now, restart the server and go to localhost:3000. Use the form at the bottom of the page to create a new article. Go to its detail page and use the workflow buttons to approve or reject the article. You’ll find that the newly created article uses 4LRW without you having to change any client-side code. Also, try out the 3LRW retro-compatibility! Choose an article created with 3LRW and go through the workflow. It should now follow the 4LRW process.

The client “adjusts” itself to changes in the workflow. This is possible because the hypermedia-based server offers the necessary metadata.

Where to Go From Here?

Click Download Materials at the top or bottom of the tutorial to download the final project.

Good job making it all the way through! You learned to create a hypermedia-based server using Spring-HATEOAS and how it helps lower the coupling between the client and the server. You also saw how the hypermedia-based client adjusted to the changes in the review workflow.

To complete the review workflow, try introducing the illustrator into the workflow. When the editor is done, the illustrator picks that article up and designs the perfect illustration for it before handing it over to the final pass editor. Its state diagram is:

Five Level Review Workflow state diagram

Find its solution in the challenge folder of the materials.

Here are some links for further reading:

If you have questions or comments, please join the forum discussion below!