Micronaut Multitenancy Propagation

Learn how to Micronaut helps you implement Multitenancy in a set of Microservices.

Authors: Sergio del Amo

Micronaut Version: 2.5.0

1. Getting Started

In this guide, we are going to create two microservices and configure them to use multitenancy with tenant propagation.

Let’s describe the microservices you are going to build through the tutorial.

  • gateway - A microservice which resolves a Tenant ID with a Cookie and propagates the Tenant ID to outgoing requests via HTTP Header.

  • books - A microservice which uses GORM to provide data access layer with multitenacy support.

The next diagram illustrates the flow:

tokenpropagation

2. What you will need

To complete this guide, you will need the following:

  • Some time on your hands

  • A decent text editor or IDE

  • JDK 1.8 or greater installed with JAVA_HOME configured appropriately

3. Solution

We recommend that you follow the instructions in the next sections and create the app step by step. However, you can go right to the completed example.

4. Writing the App

We will be using Groovy for this guide in order to demonstrate the use of multitenancy with GORM, the powerful Groovy-based data access toolkit from the Grails framework.

5. Gateway

Create the microservice:

mn create-app example.micronaut.gateway --test=spock --lang=groovy

In the previous command, we are generating a Micronaut app and telling the CLI to use Spock as the test framework.

5.1. Multitenancy Configuration

Multi-Tenancy, as it relates to software development, is when a single instance of an application is used to service multiple clients (tenants) in a way that each tenants' data is isolated from the other.

To use the Micronaut’s multitenancy capabilities you must have the multitenancy dependency on your classpath. For example in build.gradle:

build.gradle
implementation("io.micronaut.multitenancy:micronaut-multitenancy")
gateway/src/main/resources/application.yml
micronaut:
  multitenancy:
    propagation:
      enabled: true (1)
      service-id-regex: 'books' (2)
    tenantresolver:
      cookie:
        enabled: true (3)
    tenantwriter:
      httpheader:
        enabled: true (4)
1 Enable tenant propagation.
2 Propagate the resolved tenant ID only in requests going to a particular set of services. In our example, we define a regex to match the service id books.
3 In the gateway we use the CookieTenantResolver which resolves the current tenant from an HTTP cookie.
4 We propagate the tenant with a HttpHeaderTenantWriter which writes the current tenant to a HTTP Header.

The Micronaut HTTP server supports the ability to apply filters to request/response processing in a similar, but reactive, way to Servlet filters in traditional Java applications.

Create a filter to redirect to /tenant if you attempt to access / without a cookie. That it is to say, in a situation where the application is not able to resolve the tenant ID.

gateway/src/main/groovy/example/micronaut/HomePageFilter.groovy
package example.micronaut

import io.micronaut.http.filter.ServerFilterPhase
import io.micronaut.core.async.publisher.Publishers
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.MutableHttpResponse
import io.micronaut.http.annotation.Filter
import io.micronaut.http.filter.OncePerRequestHttpServerFilter
import io.micronaut.http.filter.ServerFilterChain
import io.micronaut.multitenancy.exceptions.TenantNotFoundException
import io.micronaut.multitenancy.tenantresolver.TenantResolver
import org.reactivestreams.Publisher

import groovy.transform.CompileStatic

@CompileStatic
@Filter("/") (1)
class HomePageFilter extends OncePerRequestHttpServerFilter {

    public static final String TENANT = "/tenant"
    private final TenantResolver tenantResolver

    HomePageFilter(TenantResolver tenantResolver) { (2)
        this.tenantResolver = tenantResolver
    }

    @Override
    protected Publisher<MutableHttpResponse<?>> doFilterOnce(HttpRequest<?> request,
                                                             ServerFilterChain chain) {
        try {
            tenantResolver.resolveTenantIdentifier()
        } catch (TenantNotFoundException e) {
            return Publishers.just(HttpResponse.seeOther(URI.create(TENANT)))
        }
        chain.proceed(request)
    }

    @Override
    int getOrder() {
        ServerFilterPhase.SECURITY.order()
    }
}
1 You can match only a subset of paths with a server filter.
2 Constructor injection.

5.3. Http Client

Create an interface to encapsulate the communication with the books microservice which we will create shortly.

gateway/src/main/groovy/example/micronaut/Book.groovy
package example.micronaut

import io.micronaut.core.annotation.Introspected
import groovy.transform.CompileStatic

@CompileStatic
@Introspected
class Book {
    String title
}
gateway/src/main/groovy/example/micronaut/BookFetcher.groovy
package example.micronaut

interface BookFetcher {

    List<Book> fetchBooks()
}
gateway/src/main/groovy/example/micronaut/BookClient.groovy
package example.micronaut

import io.micronaut.context.annotation.Requires
import io.micronaut.context.env.Environment
import io.micronaut.http.annotation.Get
import io.micronaut.http.client.annotation.Client

@Client("books") (1)
@Requires(notEnv = Environment.TEST) (2)
interface BookClient extends BookFetcher {

    @Override
    @Get("/books") (3)
    List<Book> fetchBooks()
}
1 The @Client annotation uses a service id which matches the regular expression we defined in the propagation configuration.
2 We don’t want to load this bean in the test environment.
3 We configure the path /books and HTTP method of the endpoint exposed by books.

Configure the urls for the service id books. Modify application.yml

gateway/src/main/resources/application.yml
micronaut:
  http:
    services:
      books: (1)
        urls:
          - "http://localhost:8081" (2)
1 Same id we used with @Client
2 url where the books service will reside.

5.4. Home Controller

The views module provides support for view rendering on the server side and does so by rendering views on the I/O thread pool in order to avoid blocking the Netty event loop. Add the views dependency to build.gradle:

build.gradle
implementation("io.micronaut.views:micronaut-views-handlebars")

The previous dependency includes HandlebarsViewsRenderer which uses the Handlebars.java project.

Create a view which we will use in the HomeController.

gateway/src/main/resources/views/home.hbs
<!DOCTYPE html>
<html>
<head>
    <title>{{ pagetitle }}</title>
</head>
<body>
<ul>
{{#each books}}
    <li><span>{{ this }}</span></li>
{{/each}}
</ul>
<p><a href="/tenant">change tenant</a></p>
</body>
</html>

Create a HomeController which invokes BookClient::fetchBooks() and renders the books using the previous handlebar view.

gateway/src/main/groovy/example/micronaut/HomeController.groovy
package example.micronaut

import io.micronaut.http.HttpResponse
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.views.View

import groovy.transform.CompileStatic

@CompileStatic
@Controller (1)
class HomeController {

    private final BookFetcher bookFetcher

    HomeController(BookFetcher bookFetcher) { (2)
        this.bookFetcher = bookFetcher
    }

    @View("home") (3)
    @Get (4)
    HttpResponse<Map<String, Object>> index() {
        HttpResponse.ok([
                pagetitle: "Home",
                books: bookFetcher.fetchBooks().collect { it.title }
        ] as Map<String, Object>) (5)
    }

}
1 Annotate with io.micronaut.http.annotation.Controller to designate a class as a Micronaut’s controller.
2 Constructor dependency injection.
3 Use @View annotation to indicate the view name which should be used to render a view for the route.
4 You can specify the HTTP verb that a controller’s action responds to. To respond to a GET request, use the io.micronaut.http.annotation.Get annotation.
5 The model is returned containing the values read from the server

5.5. Tenant Controller

HomePageFilter redirects to /tenant when the tenant ID is not resolved. Create TenantController to handle that endpoint:

gateway/src/main/groovy/example/micronaut/TenantController.groovy
package example.micronaut

import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.cookie.Cookie
import io.micronaut.multitenancy.tenantresolver.CookieTenantResolverConfiguration
import io.micronaut.views.View
import groovy.transform.CompileStatic

@CompileStatic
@Controller("/tenant") (1)
class TenantController {
    private final CookieTenantResolverConfiguration cookieTenantResolverConfiguration

    TenantController(CookieTenantResolverConfiguration cookieTenantResolverConfiguration) { (2)
        this.cookieTenantResolverConfiguration = cookieTenantResolverConfiguration
    }

    @View("tenant") (3)
    @Get (4)
    HttpResponse<Map<String, Object>> index() {
        HttpResponse.ok([
                pagetitle: "Tenants",
                tenants: ["sherlock", "watson"]
        ] as Map<String, Object>)
    }

    @Get("/{tenant}") (5)
    HttpResponse tenant(String tenant) {
        final String path = "/"
        final String cookieName = cookieTenantResolverConfiguration.getCookiename()
        return HttpResponse.status(HttpStatus.FOUND).headers((headers) ->
                headers.location(URI.create(path))
        ).cookie(Cookie.of(cookieName, tenant).path(path)) (6)
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /tenant.
2 Constructor injection of CookieTenantResolverConfiguration. A configuration object which is used by the CookieTenantResolver`
3 Use @View annotation to indicate the view name which should be used to render a view for the route.
4 You can specify the HTTP verb that a controller’s action responds to. To respond to a GET request, use the io.micronaut.http.annotation.Get annotation.
5 Define a path variable tenant.
6 Do a 302 redirect to / setting a cookie with the selected tenant ID.

5.6. Tenant View

The previous controller renders the tenant view.

gateway/src/main/resources/views/tenant.hbs
<!DOCTYPE html>
<html>
<head>
    <title>{{ pagetitle }}</title>
</head>
<body>
<ul>
{{#each tenants}}
    <li><a href="/tenant/{{ this }}">{{ this }}</a></li>
{{/each}}
</ul>

<p><a href="/">Go to Home</a></p>
</body>
</html>

5.7. Tests

Provide a BookFetcher bean replacement for the Test environment.

gateway/src/test/groovy/example/micronaut/MockBookFetcher.groovy
package example.micronaut

import io.micronaut.context.annotation.Requires
import io.micronaut.context.env.Environment
import javax.inject.Singleton

@Singleton
@Requires(env = Environment.TEST)
class MockBookFetcher implements BookFetcher {

    @Override
    List<Book> fetchBooks() {
        [
            "The Empty Hearse",
            "The Hounds of Baskerville",
            "The Woman",
            "The Six Thatchers",
            "The Aluminium Crutch",
            "The Speckled Blonde",
            "The Geek Interpreter",
            "The Great Game",
            "The Blind Banker",
            "A Study in Pink"].collect { new Book(title: it) }
    }
}

Create a tests which verifies the flow using Geb.

Add dependencies for Geb:

build.gradle
testImplementation("org.gebish:geb-spock:4.1")
testImplementation("org.seleniumhq.selenium:htmlunit-driver:2.49.1")
gateway/src/test/resources/GebConfig.groovy
import org.openqa.selenium.htmlunit.HtmlUnitDriver

// default to use htmlunit
driver = {
    HtmlUnitDriver htmlUnitDriver = new HtmlUnitDriver()
    htmlUnitDriver.javascriptEnabled = true
    htmlUnitDriver
}

environments {
    htmlUnit {
        driver = {
            HtmlUnitDriver htmlUnitDriver = new HtmlUnitDriver()
            htmlUnitDriver.javascriptEnabled = true
            htmlUnitDriver
        }
    }
}

Create two Geb Pages:

gateway/src/test/groovy/example/micronaut/HomePage.groovy
package example.micronaut

import geb.Page

class HomePage extends Page {

    static url = "/"

    static at = { title == "Home" }

    static content = {
        li(required: false) { $('li') }
    }

    List<String> books() {
        li.collect { it.text() }
    }

    int numberOfBooks() {
        if (li.empty) {
           return 0
        }
        li.size()
    }
}
gateway/src/test/groovy/example/micronaut/TenantPage.groovy
package example.micronaut

import geb.Page

class TenantPage extends Page {

    static url = "/tenant"

    static at = { title == "Tenants" }

    static content = {
        link { $('a', text: it) }
    }

    void select(String text) {
        link(text).click()
    }
}

Write a test which verifies that when the user visits the home page without a tenant is redirected to the tenant selection page. After tenant selection, the home page loads a set of books.

gateway/src/test/groovy/example/micronaut/HomePageSpec.groovy
package example.micronaut

import geb.spock.GebSpec
import io.micronaut.context.ApplicationContext
import io.micronaut.runtime.server.EmbeddedServer
import spock.lang.AutoCleanup
import spock.lang.Shared

class HomePageSpec extends GebSpec {

    @AutoCleanup
    @Shared
    EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, [:]) (1)

    def "verify tenant can be selected works"() {
        given:
        browser.baseUrl = "http://localhost:${embeddedServer.port}" (2)

        when:
        via HomePage

        then:
        at TenantPage

        when:
        TenantPage page = browser.page(TenantPage)
        page.select("sherlock")

        then:
        at HomePage

        when:
        HomePage homePage = browser.page(HomePage)

        then:
        homePage.numberOfBooks()
    }
}
1 Start an EmbeddedServer.
2 Point the browser base url to the embedded server url.

6. Books Microservice

Create the microservice:

mn create-app example.micronaut.books --lang=groovy

6.1. GORM

GORM is a powerful Groovy-based data access toolkit for the JVM. To use it in Micronaut add the following dependencies to build.gradle:

build.gradle
implementation("io.micronaut:micronaut-multitenancy")
implementation("io.micronaut.groovy:micronaut-multitenancy-gorm")

Configure multiple data sources as described in the GORM Multiple Data Sources documentation.

books/src/main/resources/application.yml
hibernate:
  hbm2ddl:
    auto: 'update'
dataSources:
  sherlock:
    url: 'jdbc:h2:mem:sherlockDb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE'
    username: 'sa'
    password: ''
    driverClassName: 'org.h2.Driver'
  watson:
    url: 'jdbc:h2:mem:watsonDb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE'
    username: 'sa'
    password: ''
    driverClassName: 'org.h2.Driver'

6.2. Domain

GORM supports several tenancy modes. In this tutorial we use DATABASE where a separate database with a separate connection pool is used to store each tenants data.

Add the following configuration to application.yml

books/src/main/resources/application.yml
grails:
  gorm:
    multiTenancy:
      mode: 'DATABASE' (1)
      tenantResolverClass: 'io.micronaut.multitenancy.gorm.HttpHeaderTenantResolver' (2)
1 Use DATABASE mode.
2 Use HttpHeaderTenantResolver which resolves the tenant ID from an HTTP header. Remember we configured the gateway microservice to propagate the tenant ID in an HTTP Header.

Create a GORM Entity to persist books:

books/src/main/groovy/example/micronaut/Book.groovy
package example.micronaut

import grails.gorm.MultiTenant
import grails.gorm.annotation.Entity
import org.grails.datastore.gorm.GormEntity

@Entity (1)
class Book implements GormEntity<Book>, (2)
                MultiTenant { (3)
    String title

    static constraints = {
        title nullable: false, blank: false
    }
}
1 GORM entities are annotated with grails.persistence.Entity.
2 Use of GormEntity is merely to aid IDE support outside of Grails. When used inside a Grails context, some IDEs will use the grails-app/domain location as a hint to enable code completion.
3 Implement the MultiTenant trait in the GORM entities you want to be regarded as multi tenant.

6.3. Data Service

GORM Data Services take the work out of implemented service layer logic by adding the ability to automatically implement abstract classes or interfaces using GORM logic.

books/src/main/groovy/example/micronaut/BookService.groovy
package example.micronaut

import grails.gorm.multitenancy.WithoutTenant
import grails.gorm.multitenancy.CurrentTenant
import grails.gorm.services.Service

@CurrentTenant (1)
@Service(Book) (2)
interface BookService {
    Book save(String title)
    List<Book> findAll()

    @WithoutTenant
    void delete(Serializable id)

}
1 Resolve the current tenant for the context of a class or method
2 The @Service annotation is an AST transformation that will automatically implement the service for you.

6.4. Controller

Create a controller to expose the /books endpoint.

books/src/main/groovy/example/micronaut/BookResponse.groovy
package example.micronaut

import groovy.transform.CompileStatic

@CompileStatic
class BookResponse {
    String title
}
books/src/main/groovy/example/micronaut/BookController.groovy
package example.micronaut

import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import groovy.transform.CompileStatic

@CompileStatic
@Controller("/books") (1)
class BookController {

    private final BookService bookService

    BookController(BookService bookService) { (2)
        this.bookService = bookService
    }

    @Get
    List<BookResponse> index() {
        bookService.findAll().collect { new BookResponse(title: it.title) }
    }
}
1 Annotate with io.micronaut.http.annotation.Controller to designate a class as a Micronaut’s controller.
2 Constructor dependency injection.

6.5. Bootstrap

To listen to an event, register a bean that implements ApplicationEventListener where the generic type is the type of event the listener should be executed for.

We want to listen for the StartupEvent to save some elements in the databases when the app starts:

books/src/main/groovy/example/micronaut/Bootstrap.groovy
package example.micronaut

import grails.gorm.multitenancy.Tenants
import io.micronaut.context.event.ApplicationEventListener
import io.micronaut.context.event.StartupEvent

import javax.inject.Inject
import javax.inject.Singleton
import io.micronaut.context.annotation.Requires
import io.micronaut.context.env.Environment
import groovy.transform.CompileStatic

@CompileStatic
@Requires(notEnv = Environment.TEST) (1)
@Singleton (2)
class Bootstrap implements ApplicationEventListener<StartupEvent> { (3)

    @Inject (4)
    BookService bookService

    @Override
    void onApplicationEvent(StartupEvent event) {
        Tenants.withId("sherlock") { (5)
            bookService.save('Sherlock diary')
        }
        Tenants.withId("watson") { (6)
            bookService.save('Watson diary')
        }
    }
}
1 This bean will not be loaded for the test environment.
2 To register a Singleton in Micronaut’s application context, annotate your class with javax.inject.Singleton.
3 Listen to StartupEvent.
4 Field injection
5 You can specify the tenant ID with the Tenants.withId method.

6.6. Book Tests

Create a tests which verifies the behaviour. We received the books belonging to the tenant which we send via an HTTP header.

books/src/test/groovy/example/micronaut/BookControllerSpec.groovy
package example.micronaut

import grails.gorm.multitenancy.Tenants
import io.micronaut.core.type.Argument
import io.micronaut.http.HttpRequest
import io.micronaut.http.client.RxHttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import spock.lang.Specification
import javax.inject.Inject

@MicronautTest
class BookControllerSpec extends Specification {

    @Inject
    @Client("/")
    RxHttpClient client

    @Inject
    BookService bookService

    void "test hello world response"() {
        given:
        List<Long> ids = []
        Tenants.withId("sherlock") {
            ids << bookService.save('Sherlock diary').id
        }
        Tenants.withId("watson") {
            ids << bookService.save('Watson diary').id
        }

        when:
        HttpRequest<?> request = booksRequest("sherlock")
        List<BookResponse> rsp  = client.toBlocking().retrieve(request, Argument.listOf(BookResponse))

        then:
        rsp
        rsp.size() == 1
        rsp.first().title == 'Sherlock diary'

        when:
        request = booksRequest("watson")
        rsp  = client.toBlocking().retrieve(request, Argument.listOf(BookResponse))

        then:
        rsp.size() == 1
        rsp.first().title == 'Watson diary'

        cleanup:
        ids.each {
            bookService.delete(it)
        }
    }

    private static HttpRequest<?> booksRequest(String tenantId) {
        HttpRequest.GET('/books')
                .header("tenantId", tenantId)
    }
}

7. Running the app

Run both microservices:

books $ ./gradlew run

18:29:26.500 [main] INFO  io.micronaut.runtime.Micronaut - Startup completed in 671ms. Server Running: http://localhost:8081
<=========----> 75% EXECUTING [10s]
gateway $ ./gradlew run

18:28:35.723 [main] INFO  io.micronaut.runtime.Micronaut - Startup completed in 707ms. Server Running: http://localhost:8080

You can visit http://localhost:8080 and change tenant and see the book list change:

multitenancy

8. Next Steps

Read more about Multitenancy inside Micronaut and GORM Multitenancy support.

9. Help with Micronaut

Object Computing, Inc. (OCI) sponsored the creation of this Guide. A variety of consulting and support services are available.