Monolith to Micro/Macro Services in the World of Spring

by Ghenadie Cebanu

How can we use Spring Cloud Gateway to transition from a monolithic web project to a micro/macro service architecture?

Precondition

Let us imagine a web project that is serving a mobile app and one website through a REST API, everything tight & coupled in one big monolith box.

Monolith project: https://github.com/GhenadieCebanu/monolit-app

API endpoints:
EventController – https://{{hostname}}/v1/get-out/events
UsersController – https://{{hostname}}/ v1/get-out/users

Problem

EventController is highly used and we want to scale it up. On the other hand, UsersController is less used and does not require a lot of resources. The only option for this scenario is to scale up the entire monolith app (we want instant response for our users), but we all know that AWS services are not cheap.

Possible solution

Break down the monolith app in two separate services so we can scale them up/down independently and, at the same time, preserve the API contract because of the mobile apps (forcing users to update iOS or Android app should be the last option).

and our star here – API gateway project, based on Spring Cloud Gateway

Spring Cloud Gateway

Before describing how we can use it, I would like to mention some benefits, which come with this framework.

  • It will protect your client from freezing – you can set timeout and fallback URL for any request, so the client will not freeze waiting for a response. You can read here an insightful article from Uncle Bob (Robert C. Martin) about CircuitBreaker and how it works.
  • It can be used as a central place for logging request/responses, for setting up SSL communication, for setting security configuration, and more (for all these cases, the gateway will be set as the only entry point).
  • Intuitive to use.
  • Highly configurable.

Set up routes in Spring Cloud Gateway

We want to keep the same contract with the client, in our case the following two URLs must be accessible:

  1. http://{{hostname}}/v1/get-out/events
  2. http://{{hostname}}/ v1/get-out/users

In RoutesConfig, we can set up like this:.route(r -> r.path(“/v1/get-out/events”) .uri(serviceDiscoveryProperties.eventsServiceUri)) .route(r -> r.path(“/v1/get-out/users”) .uri(serviceDiscoveryProperties.userManagementServiceUri))

.route(r -> r.path("/v1/get-out/events")
      .uri(serviceDiscoveryProperties.eventsServiceUri))

.route(r -> r.path("/v1/get-out/users")
      .uri(serviceDiscoveryProperties.userManagementServiceUri))

This is the simplest configuration that will redirect all requests with path “/v1/get-out/events” to the event management project and “/v1/get-out/users” to the user management project.

In order to test this, we have a nice library, wiremock, which can be used to mock behavior for any endpoint; below, you can find a test example:

@Test
void shouldHitEventsEndpoint() {
// Given
stubFor(get("/v1/get-out/events").willReturn(ok("Found")));

// When, Then
webTestClient.get().uri("/v1/get-out/events").exchange()
.expectStatus().isOk();
}

(https://github.com/GhenadieCebanu/api-gateway/blob/master/src/test/java/api/gateway/config/RoutesConfigITest.java)

Let’s enhance a bit our routing by adding custom request and response headers, and break the call if the request takes longer than 1 second.

.route(r -> r.path("/v1/get-out/events")
    .filters(f -> f
        .circuitBreaker(c -> {
        })
        .addRequestHeader("view", "legacy")
        .addResponseHeader("response-service", "event-management")
    )
    .uri(serviceDiscoveryProperties.eventsServiceUri))

An example configuration for circuitbreaker can be found here: api.gateway.config.CircuitBreakerConfig

Request timeout duration can be set here: api.gateway.config.CircuitBreakerProperties

 

Let’s suppose it’s the first release of our user management service; for safety reasons, we can add Fallback URI to legacy (old monolith app) – please check the configuration below:

.route(r -> r.path("/v1/get-out/events")
    .filters(f -> f
        .circuitBreaker(c -> {
          c.setFallbackUri("forward:/fallback/v1/get-out/events");
        })
        .addRequestHeader("view", "legacy")
        .addResponseHeader("response-service", "event-management")
    )
    .uri(serviceDiscoveryProperties.eventsServiceUri))
.route(r -> r.path("/fallback/v1/get-out/**")
    .filters(f -> f
        .rewritePath("/fallback/(?<segment>.*)", "/${segment}")
        .addResponseHeader("response-service", "monolit-app"))
    .uri(serviceDiscoveryProperties.fallbackUri))

We can also set up a retry policy for each endpoint:

.route(r -> r.path("/v1/get-out/users")
    .filters(f -> f
        .retry(retryConfig -> {
          retryConfig.setRetries(3);
          retryConfig.setBackoff(Duration.ofSeconds(2), Duration.ofSeconds(16), 2, true);
          retryConfig.setStatuses(HttpStatus.GATEWAY_TIMEOUT, HttpStatus.REQUEST_TIMEOUT,
              HttpStatus.BAD_REQUEST, HttpStatus.NOT_FOUND);
        })
        .addResponseHeader("response-service", "user-management")
    )
    .uri(serviceDiscoveryProperties.userManagementServiceUri))

Using the above configuration, in case the user management service will return any response status that is configured in retryConfig.setStatuses, then the gateway will retry 3 times, first retry after 2 sec., then 4s., 8s., and the last one after 16sec (retry timing depends on user management service).

Log request and response

By implementing org.springframework.cloud.gateway.filter.GlobalFilter we gain access to org.springframework.web.server.ServerWebExchange that have requests/responses – please check api.gateway.filter.HttpRequestLoggerFilter and api.gateway.filter.HttpResponseLoggerFilter.

Other magical stuff

  • We can build a redirect URI based on the request body

Example:

.route(r -> r.path("/v1/get-out/users")
    .and()
    .method(POST)
    .and()
    .readBody(String.class, s -> !s.isBlank())
    .filters(f -> f
        .circuitBreaker(c -> {
        })
        .filter(bodyToUriFilterFactory.apply(
            new SetValueFromBodyToPath("/v1/get-out/users")
        ))
        .filter((exchange, chain) -> {
          final ServerHttpRequest request = exchange.getRequest();
          final ServerHttpRequest newRequest = request.mutate().method(GET).build();
          return chain.filter(exchange.mutate().request(newRequest).build());
        }))
    .uri(serviceDiscoveryProperties.userManagementServiceUri))

and test:

@Test
void shouldTranslatePostToGet() {
  // Given
  stubFor(get("/v1/get-out/users/1").willReturn(ok("Found")));

  // When, Then
  webTestClient.post().uri("/v1/get-out/users")
      .contentType(APPLICATION_JSON).bodyValue(Map.of("id","1"))
      .exchange().expectStatus().isOk();
}

Here we redirected POST to GET and used the body to build GET URI (/v1/get-out/users/1), you can find this example in RoutesConfig.java.

@Component
public class GlobalHeaderFilter implements GlobalFilter, Ordered {

  @Override
  public int getOrder() {
    return Ordered.HIGHEST_PRECEDENCE + 1;
  }

  @Override
  public Mono<Void> filter(final ServerWebExchange exchange, final GatewayFilterChain chain) {
    exchange.getResponse().getHeaders().set("global-header", "header-value");
    return chain.filter(exchange);
  }

}

This is one way to transition from monolith to micro/macro services. I hope you find it useful.

Ghenadie Cebanu, Senior Software Developer at Maxcode

About Ghenadie Cebanu

A graduate of Computer Science BSc and Information Security MA, Ghenadie has over 7 years of experience in software development (primary language Java). One thing he enjoys when working closely with a client is the opportunity to understand the business needs, not only the technical aspects of the project and the tasks involved. Ghenadie is also passionate about photography, cycling, hiking, and table tennis. Check out Ghenadie’s photos on Flickr and Unsplash.

 

Share this article