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:
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.
-
Download and unzip the source
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:
implementation("io.micronaut.multitenancy:micronaut-multitenancy")
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. |
5.2. HTTP Filter & Cookie Redirect
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.
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.
package example.micronaut
import io.micronaut.core.annotation.Introspected
import groovy.transform.CompileStatic
@CompileStatic
@Introspected
class Book {
String title
}
package example.micronaut
interface BookFetcher {
List<Book> fetchBooks()
}
Create a declarative HTTP client:
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
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:
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
.
<!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.
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:
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.
<!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.
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:
testImplementation("org.gebish:geb-spock:@geb-spockVersion@")
testImplementation("org.seleniumhq.selenium:htmlunit-driver:@htmlunit-driverVersion@")
Add a Geb configuration script:
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:
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()
}
}
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.
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:
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.
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
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:
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.
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.
package example.micronaut
import groovy.transform.CompileStatic
@CompileStatic
class BookResponse {
String title
}
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:
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.
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:
./gradlew run
18:29:26.500 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 671ms. Server Running: http://localhost:8081
<=========----> 75% EXECUTING [10s]
./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:
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.