Micronaut Multitenancy Propagation

Learn how the Micronaut framework helps you implement multitenancy in a set of microservices.

Authors: Sergio del Amo

Micronaut Version: 3.9.2

1. Getting Started

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

Let’s describe the microservices you will build through the guide.

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

  • books - A microservice that uses GORM to provide a 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 application 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

The previous command generates a Micronaut application and tells the CLI to use Spock as the test framework.

5.1. Multitenancy Configuration

Multitenancy, as it relates to software development, is when a single instance of an application services multiple clients (tenants) in a way that each tenant’s data is isolated from the others.

To use Micronaut multitenancy capabilities, you must have the multitenancy dependency:

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.HttpRequestTenantResolver
import org.reactivestreams.Publisher
import groovy.transform.CompileStatic

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

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

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

    @Override
    protected Publisher<MutableHttpResponse<?>> doFilterOnce(HttpRequest<?> request,
                                                             ServerFilterChain chain) {
        try {
            tenantResolver.resolveTenantIdentifier(request)
        } 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; it does so by rendering views on the I/O thread pool in order to avoid blocking the Netty event loop. Add the views dependency:

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 that 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 controller.
2 Constructor dependency injection.
3 Use @View annotation to indicate the view name that should be used to render a view for the route.
4 You can specify the HTTP verb that a controller 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 that’s used by the CookieTenantResolver`
3 Use @View annotation to indicate the view name that should be used to render a view for the route.
4 You can specify the HTTP verb that a controller 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 jakarta.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 test to verify the flow using Geb.

Add dependencies for Geb:

build.gradle
testImplementation("org.gebish:geb-spock:@geb-spockVersion@")
testImplementation("org.seleniumhq.selenium:htmlunit-driver:@htmlunit-driverVersion@")
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 to verify that a user visiting 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 a Micronaut application, add the following dependencies:

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 guide we use DATABASE, where a separate database with a separate connection pool stores each tenant’s 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
import io.micronaut.core.annotation.Introspected

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

    static constraints = {
        title nullable: false, blank: false
    }
}
1 GORM entities should be annotated with grails.gorm.annotation.Entity.
2 Use of GormEntity to aid IDE support. 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 multitenant.

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 The class is defined as a controller with the @Controller annotation mapped to the path /books.
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 application 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 jakarta.inject.Inject
import jakarta.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 Use jakarta.inject.Singleton to designate a class as a 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 test to verify 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.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import spock.lang.Specification
import jakarta.inject.Inject

@MicronautTest
class BookControllerSpec extends Specification {

    @Inject
    @Client("/")
    HttpClient 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 Application

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 the tenant and see the book list change:

multitenancy

8. Next Steps

Read more about Multitenancy support in the Micronaut framework and GORM Multitenancy Support.

9. Help with the Micronaut Framework

The Micronaut Foundation sponsored the creation of this Guide. A variety of consulting and support services are available.