Multitenant Services with Micronaut
A software system is considered Multitenant when it can serve multiple clients with different data. Each registered user is part of one tenant, and will have access to the data that their tenant owns only.
In this article I will be covering how the Micronaut framework supports the creation of multi-tenant applications, as well as many other useful features for general development.
If you are not familiar with Micronaut, I recomend taking a look at the official documentation to understand tha basic concepts before approaching this article.
Sample Service
I will implement a very simple catalog service for a market-place site, a multi-merchant e-commerce where different companies sell their products under a common name, but have unique data.
The data model will contain the following entities:
- Merchant: a company or individual selling items on the site.
- Product: an item that one of the merchants has available in the site.
- Category: for product classification.
- User: an individual with an account in the system, associated to a merchant.
Make a note that both Products and Users belong to a Merchant. The idea here is that when a user is logged in the system, they should only see and manage the products of the Merchant they work for.
The source code for this example is in this GitHub repository. I have created different tags to make the development process easier to follow.
The API specification for the service is defined in an OpenAPI document, and defines endpoints for product management only - user registry, login, logout etc. are out of scope for this article. If you are curious as to how to develop REST services API-first, take look at my article about Behaviour-Driven Development.
User authentication will be done via JWT tokens. I will not cover all the details here, but you can read about JWT authentication with Micronaut here.
Micronaut integrates the OpenAPI code generator out of the box, so everytime you run the mvn compile
command the interface for your controller and the (API) model classes are re-generated.
Phase 1: Single Tenant Authentication
The code for the service at this point is in the single-tenant-service tag.
Before considering multitenancy, we need a way to identify users and restrict access to certain areas. This functionality is covered by Micronaut Security, a feature that provides authentication and authorization for your application.
The main artifact in Micronaut Security is the Security Filter, that intercepts every request to evaulate the provided authentication (if any) and authorize or deny the execution.
The Security Filter performs two operations:
-
Extract the authorization from the Request. This step supports different ways of providing authentication, such as HTTP Basic Authentication or X509 certificates. You can also provide a custom implementation if needed by registering a bean that implements AuthenticationFetcher
. -
Evaluate the security rules and decide if the authorization provided, or the lack of it, is sufficient for the request. Several rules can be defined, and again you can provide your own by implementing the SecurityRule
interface..
In this example I will use JWT tokens to identify users, and will define the access rules with the @Secured
annotation, so the main components will be TokenAuthenticationFetcher and SecuredAnnotationRule, marked in green in the diagram.
The easiest way to understand how the authentication works is to run ProductControllerTest:
@MicronautTest
class ProductControllerTest {
@Inject ApiClient apiClient;
@Inject ProductRepository productRepository;
@Inject ProductMapper productMapper;
@Test
@DisplayName(
"Expect that when a product is in the system it will be returned by the getProductById operation")
void expectProductsRetrievedById() {
var merchant1 = MerchantMother.random();
var merchant2 = MerchantMother.random();
merchantRepository.add(List.of(merchant1, merchant2));
var user = UserMother.randomInMerchant(merchant1);
authenticationClientFilter.setAuthenticatedUser(user);
var category = CategoryMother.random();
categoryRepository.add(List.of(category));
var product1 = ProductMother.randomInMerchant(merchant1, category);
var product2 = ProductMother.randomInMerchant(merchant2, category);
productRepository.add(List.of(product1, product2));
/*
At this point there is no multitenancy support, so the same user should be able to fetch both products,
even when only one of them is in their same merchant.
*/
var result1 = apiClient.getProductById(product1.getId().toUuid());
assertNotNull(result1);
assertEquals(productMapper.from(product1), result1);
var result2 = apiClient.getProductById(product2.getId().toUuid());
assertNotNull(result2);
assertEquals(productMapper.from(product2), result2);
}
@Client("/")
@Header(name = ACCEPT, value = "application/vnd.github.v3+json, application/json")
private interface ApiClient extends ProductApi {}
}
- The test is annotated as
@MicronautTest
so that it will run against the Micronaut server with the full application context. - The
ApiClient
interface extends the interface generated from the OpenAPI spec, so it presents the same contract as the controller. The@Client
annotation is part of Micronaut HTTP Client feature, that will generate a class that can call the real service endpoints.
This test creates two products of different merchants and then calls the API to retrieve them, verifying that the objects received in the response match the original products (we are using a mapper to translate the domain layer entities to the classes generated from the OpenAPI spec).
Pay attention to these lines:
var user = UserMother.randomInMerchant(merchant1);
authenticationClientFilter.setAuthenticatedUser(user);
The first one creates a random user for this test, belonging to the merchant1
merchant. The UserMother class is written after the Object Mother pattern, which I find very useful to create re-usable test objects and avoid polluting the tests with factory code.
The second line sets that user as authenticated in the client filter. The filter will create the authentication token so that the request is done on behalf of the given user:
@RequestFilter
public void doFilter(MutableHttpRequest<?> request) {
if (authenticatedUser == null) {
log.warn("No user registered, requests will be anonymous!");
return;
}
var roles = List.of(ROLE_USER);
Map<String, Object> attributes =
Map.of("merchantId", authenticatedUser.getMerchantId().value());
var authorization =
Authentication.build(authenticatedUser.getUserName().value(), roles, attributes);
var token = tokenGenerator.generateToken(authorization, _1H_SECONDS);
log.info("Token generated: {}", token);
request.bearerAuth(token.orElseThrow());
}
As part of the authentication process, the merchantId of the logged in user will be added to the JWT token. Our token payload will be something like this:
{
"sub": "bernie.reichert",
"nbf": 1745086243,
"merchantId": "b8638c13-ca8f-42f3-b356-5c44e2519e82",
"roles": [
"ROLE_USER"
],
"iss": "multitenant",
"exp": 1745089843,
"iat": 1745086243
}
You can review the code at this stage in the single-tenant-service tag.
We have a service that requires users to be logged-in to access the API, but is lacking the logic to implement multitenancy support. Any user will be able to access every stored product.
Let’s fix that.
Phase 2: Adding Multitenancy
The code for the service at this point is in the multi-tenancy-support tag.
Micronaut Multitenancy is the feature that adds support for tenant resolution and propagation.
The module ships with a filter that intercepts requests and tries to resolve the tenancy. There are several TenancyResolver implementations, and you need to enable in your configuration the one(s) you need to use:
- CookieTenantResolver: Resolves the current tenant from an HTTP cookie.
- FixedTenantResolver: Resolves against a fixed tenant id.
- HttpHeaderTenantResolver: Resolves the current tenant from the request HTTP Header.
- PrincipalTenantResolver: Resolves the current tenant from the authenticated username.
- SessionTenantResolver: Resolves the current tenant from the HTTP session.
- SubdomainTenantResolver: Resolves the tenant id from the subdomain.
- SystemPropertyTenantResolver: Resolves the tenant id from a system property.
Or you can create your own implementation of the TenantResolver interface if you have a different use case.
Once identified, you can access the tenant identifier in your code in two possible ways:
- You can bind a controller parameter of type Tenant:
@Controller("/")
class TenantBindingController {
@Secured(SecurityRule.IS_AUTHENTICATED)
@Get
Serializable action(Tenant tenant) {
...
var tenantId = tenant.id();
...
}
}
- Or you can read the tenant identifier from the
tenantIdentifier
request attribute.
Architecture Discussion
Now that we know how the tenancy resolution works, let us stop for a moment and design our solution. As we have seen, one can bind the tenant identifier to a parameter in the controller and propagate it from there to the business layer. But this is not something I would recommend for real-life applications.
Multi-tenancy is a cross-cutting feature that, if implemented directly in the application code, will permeate every layer of the architecture, introducing multiple opportunities for human error and misinterpretation.
As the application evolves, or different developers work in the code, the chances to get the multitenant requirement wrong at some point are high. And this could lead to critical security vulnerabilities or nasty functional bugs.
The safest option here is to implement multi-tenancy in an isolated component, or set of components, that would make it work automagically. Something like this:
- Each user is associated with a tenant (in our example, a Merchant).
- Authentication tokens contain the tenant identifier (merchantId) as part of their payload.
- When the merchantId field is read from the the token, it will be automatically propagated to the affected areas without altering the domain logic, when possible.
Once the the multi-tenancy code is in place, the previous test should simply fail on the second call, when we request the product that belongs to a different Merchant.
So the fist thing we will do is alter that test to reflect the change. Among other changes to better organize the code, the relevant part is this:
@Test
@DisplayName("Expect that only products in the same Merchant are returned by the API")
void expectProductsRetrievedById() {
var result1 = apiClient.getProductById(product1.getId().toUuid());
assertNotNull(result1);
assertEquals(productMapper.from(product1), result1);
// product2 does not belong to the user's merchant, so it should not be visible to them:
var result2 = apiClient.getProductById(product2.getId().toUuid());
assertNull(result2);
}
(see the full test here)
Proposed solution
The main decision here is to make the tenancy management logic transparent for the developers working on general use cases. They will work as if there was a single merchant in the system. When a user interacts with the system, products that belong to a different merchant will be hidden, as if they did not exist in the data store.
To do this, we will use another great feature in Micronaut: the posibility to replace any bean by a proxy by listening to the BeanCreatedEvent. We will wrap the ProductRepository
with a new class that will take care of the tenancy logic.
The following new artifacts will be added in the es.nachobrito.multitenant.infrastructure.multitenant
package:
- MerchantResolver: Interface for a component that any bean can inject if it needs to know the currently active Merchant:
public interface MerchantResolver {
Optional<Merchant> getCurrentMerchant();
}
- TokenTenantResolver: A new bean that implements the Micronaut Multitenancy interfaces, as well as MerchantResolver. This component is responsible for reading the
tenancyId
property from the currentAuthentication
object - retrieved from the SecurityService - and loading the Merchant from the data store:
@Singleton
public class TokenTenantResolver
implements TenantResolver, HttpRequestTenantResolver, MerchantResolver {
private final Logger log = LoggerFactory.getLogger(getClass());
private final SecurityService securityService;
private final MerchantRepository merchantRepository;
public TokenTenantResolver(
SecurityService securityService, MerchantRepository merchantRepository) {
this.securityService = securityService;
this.merchantRepository = merchantRepository;
}
@NonNull
@Override
public String resolveTenantId() {
var authentication = securityService.getAuthentication();
return authentication
.map(this::extractMerchantId)
.orElseThrow(
() ->
new TenantNotFoundException("Tenant could not be resolved!"));
}
private String extractMerchantId(@NotNull Authentication authentication) {
return (String) authentication.getAttributes().get("merchantId");
}
@Override
public Optional<Merchant> getCurrentMerchant() {
try {
var merchantId = resolveTenantId();
return merchantRepository.get(new MerchantId(merchantId));
} catch (TenantNotFoundException ex) {
log.warn("No merchant detected!");
return Optional.empty();
}
}
...
}
- MerchantAwareProductRepository: A thin wrapper (or proxy) around the
ProductRepository
that introduces the Merchant logic when necessary:
public class MerchantAwareProductRepository implements ProductRepository {
private final Logger log = LoggerFactory.getLogger(getClass());
private final ProductRepository delegate;
private final MerchantResolver merchantResolver;
public MerchantAwareProductRepository(
ProductRepository delegate, MerchantResolver merchantResolver) {
this.delegate = delegate;
this.merchantResolver = merchantResolver;
}
@Override
public List<Product> search(ProductSearch search) {
var merchant = merchantResolver.getCurrentMerchant();
if (merchant.isPresent()) {
log.info(
"Merchant resolved: {}. Injecting merchant into the product search",
merchant.get().getName().name());
search = search.withMerchant(merchant.get().getId());
}
return delegate.search(search);
}
@Override
public void add(List<Product> items) {
merchantResolver
.getCurrentMerchant()
.ifPresent(
merchant -> {
log.info("Adding {} products to merchant {}", items.size(), merchant.getId());
items.forEach(product -> product.setMerchantId(merchant.getId()));
});
delegate.add(items);
}
@Override
public Optional<Product> get(ProductId productId) {
// return the product only if it's in the current merchant
return delegate.get(productId).filter(this::matchesMerchant);
}
/**
* Verifies if the product matches the merchant
*
* @param product the product
* @return whether the provided product belongs to the current merchant. If there is no active
* merchant, this method returns true.
*/
private boolean matchesMerchant(@NotNull Product product) {
var merchant = merchantResolver.getCurrentMerchant();
var matches = merchant.map(value -> value.getId().equals(product.getMerchantId()));
log.info("Product {} matches current merchant {}? {}", product, merchant, matches);
return matches.orElse(true);
}
@Override
public void delete(ProductId productId) {
// delete the product only if it is found by the get method (verifying merchant)
this.get(productId).map(Product::getId).ifPresent(delegate::delete);
}
}
- ProductRepositoryListener: A Bean Event Listener that will replace the current
ProductRepository
with aMerchantAwareProductRepository
wrapper.
@Singleton
public class ProductRepositoryListener implements BeanCreatedEventListener<ProductRepository> {
private final MerchantResolver merchantResolver;
public ProductRepositoryListener(MerchantResolver merchantResolver) {
this.merchantResolver = merchantResolver;
}
@Override
public ProductRepository onCreated(@NonNull BeanCreatedEvent<ProductRepository> event) {
return new MerchantAwareProductRepository(event.getBean(), merchantResolver);
}
}
Other artifacts have been modified to support this new feature, take a look at this commit to review all the changes.
Phase 3: Real database
I know. If you have read to this point, I know you have thought, at least once, “wait, but he is using an in-memory repository all the time, what if there is a real database”?
There are several benefits in working with an in-memory repository first, including:
- You force yourself to solve the use cases in plain and boring source code, avoiding the temptation of delegating your domain logic to the database or any other external service.
- Most of your logic can be covered by unit tests and you won’t even need to use mocks.
- You will make sure that your design works with any persistence mechanism, thus following the Liskov Substitution Principle.
But, to be fair, a solution will only be complete if we can use it with a real database. So let’s do that.
Database access toolbox
Micronaut’s solution for database access, Micronaut Data, leverages compile-time processing to pre-compute queries and avoid having a runtime model. This eliminates the need for using reflection, reducing the memory footprint and start-up time of your service.
Furthermore, Micronaut Data supports Multitenancy out of the box. You can choose any of these options:
- Use a discriminator column to separate data from different Tenants.
- Have different schemas for each Tenant.
- Use a dedicated datasource per Tenant.
Micronaut Data is a database access toolkit, supporting several backends including JPA (powered by Hibernate). But, to further reduce the memory requirements and start-up times, it also supports plain JDBC, providing direct SQL generation at compile time and native result sets.
Citing from the oficial documentation:
Micronaut Data JDBC / R2DBC supports all the features of Micronaut Data for JPA including dynamic finders, pagination, projections, Data Transfer Objects (DTO), Batch Updates, Optimistic locking and so on.
However, Micronaut Data JDBC / R2DBC is not an Object Relational Mapping (ORM) implementation and does not and will not include any of the following concepts:
- Lazy Loading or Proxying of Associations
- Dirty Checking
- Persistence Contexts / Sessions
- First Level Caching and Entity Proxies
Micronaut Data JDBC / R2DBC is designed for users who prefer a lower-level experience and working directly with SQL.
Proposed solution
This is the final stage, the code at this stage is in the main branch.
Again, I will start with a test. A new integration test verifies the same logic that the previous one, but with an underlying database:
@MicronautTest(environments = {"it"})
@Sql(scripts = "classpath:sql/test-data-up.sql", phase = Sql.Phase.BEFORE_EACH)
@Sql(scripts = "classpath:sql/test-data-down.sql", phase = Sql.Phase.AFTER_EACH)
public class ProductionControllerIT {
@Inject ApiClient apiClient;
@Inject AuthenticationClientFilter authenticationClientFilter;
@Inject ProductMapper productMapper;
@Test
@DisplayName("Expect that only products in the same Merchant are returned by the API")
void expectProductsRetrievedById() {
var user = UserMother.inMerchant1();
var product1 = ProductMother.inMerchant1();
var product2 = ProductMother.inMerchant2();
authenticationClientFilter.setAuthenticatedUser(user);
var result1 = apiClient.getProductById(product1.getId().toUuid());
assertNotNull(result1);
assertEquals(productMapper.from(product1), result1);
// product2 does not belong to the user's merchant, so it should not be visible to them:
var result2 = apiClient.getProductById(product2.getId().toUuid());
assertNull(result2);
}
}
- The annotation
@MicronautTest(environments = {"it"})
enables the “it” environment, in which there will be a databse (in this case an H2 in-memory one). - Two
@Sql
annotations define scripts to load and remove test data before and after each test.
To execute the integration tests, run the following command:
mvn integration-test
A new properties file for the “it” environment configures the datasource, and enables flyway migrations:
datasources.default.dialect=H2
datasources.default.driver-class-name=org.h2.Driver
datasources.default.url=jdbc\:h2\:mem\:devDb;LOCK_TIMEOUT\=10000;DB_CLOSE_ON_EXIT\=FALSE
datasources.default.username=sa
datasources.default.password=
flyway.datasources.default.enabled=true
Additionally, all the beans related to the in-memory data storage (the InMemoryXXX classes, and the Listener) are marked with the following annotation:
@Requires(missingBeans = javax.sql.DataSource.class)
This way those beans will be enabled only when there is no real database. The moment we enable one, for example by activating the “it” environment, the beans will be replaced by a different set defined in the es.nachobrito.multitenant.infrastructure.data
package.
I have added the following artifacts for each entity:
- A class marked as
@MappedEntity
that represents the persistent version of that entity, where:- Each persistent entity has a factory method to build it from a domain model entity.
- They also have a
toDomainModel
method to convert instances to domain model entities. - In the entities that belong to a merchant, the corresponding field is marked with
@TenantId
(more on this later)
@MappedEntity("PRODUCT")
class JdbcProduct {
@Id private UUID uuid;
@TenantId private UUID merchantId;
private UUID categoryId;
private String name;
static JdbcProduct of(Product product) {
var entity = new JdbcProduct();
entity.setName(product.getName().value());
entity.setCategoryId(product.getCategoryId().toUuid());
entity.setMerchantId(product.getMerchantId().toUuid());
entity.setUuid(product.getId().toUuid());
return entity;
}
// getters, setters, equals, hashCode
...
Product toDomainModel() {
return Product.with(
ProductId.of(this.uuid),
MerchantId.of(this.merchantId),
new ProductName(this.name),
CategoryId.of(categoryId));
}
}
- A Micronaut Data repository interface. This will inherit basic methods from
CrudRepository
, and define other queries as dynamic finder methods:
@JdbcRepository(dialect = Dialect.H2)
public interface ProductJdbcRepository extends CrudRepository<JdbcProduct, UUID> {
List<JdbcProduct> findByNameContains(String nameExpression);
List<JdbcProduct> findByCategoryId(UUID categoryId);
List<JdbcProduct> findByCategoryIdOrNameContains(UUID categoryId, String nameExpression);
}
- An implementation of the domain repository interface that delegates all the operations to the CRUD repositories, using the methods in the persistent entities to translate between persistence and domain models:
@Singleton
@Requires(beans = javax.sql.DataSource.class)
public class ProductRepository
implements es.nachobrito.multitenant.domain.model.product.ProductRepository {
private final ProductJdbcRepository productJdbcRepository;
public ProductRepository(ProductJdbcRepository productJdbcRepository) {
this.productJdbcRepository = productJdbcRepository;
}
@Override
public List<Product> search(ProductSearch productSearch) {
//we will use the type of query to decide which dynamic finders to use
//as we support more search types, we would replace this if with a switch statement.
if (!(productSearch instanceof ProductSearchByNameOrCategory)) {
throw new IllegalArgumentException(
"Unknown query type! %s".formatted(productSearch.getClass().getName()));
}
return searchByNameOrCategory((ProductSearchByNameOrCategory) productSearch);
}
private List<Product> searchByNameOrCategory(ProductSearchByNameOrCategory search) {
// Filter by category and name
if (search.category() != null && search.name() != null) {
return productJdbcRepository
.findByCategoryIdAndNameContains(search.category().toUuid(), search.name())
.stream()
.map(JdbcProduct::toDomainModel)
.toList();
}
// filter by category
if (search.category() != null) {
return productJdbcRepository.findByCategoryId(search.category().toUuid()).stream()
.map(JdbcProduct::toDomainModel)
.toList();
}
// filter by name
return productJdbcRepository.findByNameContains(search.name()).stream()
.map(JdbcProduct::toDomainModel)
.toList();
}
@Override
public void add(List<Product> items) {
productJdbcRepository.saveAll(items.stream().map(JdbcProduct::of).toList());
}
@Override
public Optional<Product> get(ProductId entityId) {
return productJdbcRepository.findById(entityId.toUuid()).map(JdbcProduct::toDomainModel);
}
@Override
public void delete(ProductId entityId) {
productJdbcRepository.deleteById(entityId.toUuid());
}
}
Tenancy Id Propagation
We have seen how the entities that belong to a tenant have the tenant identifier annotated with @TenantId
. The effect of this annotation is that every query generated for the entity will contain an additional clause to filter by that value.
For example, although our get(ProductId)
method has no explicit mention to the merchant concept:
@Override
public Optional<Product> get(ProductId entityId) {
return productJdbcRepository.findById(entityId.toUuid()).map(JdbcProduct::toDomainModel);
}
The log shows how the multitenancy feature is introducing that value in the query:
DEBUG io.micronaut.data.query - Executing Query: SELECT jdbc_product_.`uuid`,jdbc_product_.`merchant_id`,jdbc_product_.`name`,jdbc_product_.`category_id` FROM `PRODUCT` jdbc_product_ WHERE (jdbc_product_.`uuid` = ? AND jdbc_product_.`merchant_id` = ?)
TRACE io.micronaut.data.query - Binding parameter at position 1 to value 87982202-54bc-41a8-b239-4d7dad5d70fd with data type: UUID
TRACE io.micronaut.data.query - Binding parameter at position 2 to value afe252cb-3daf-457d-b9e3-e2759e707a0e with data type: UUID
And the same thing happens with the dynamic finders. Any query generated by micronaut data will include the tenant clause even if it is not explicitly included. This dynamic finder:
List<JdbcProduct> findByCategoryIdAndNameContains(UUID categoryId, String nameExpression);
will generate the following trace when executed:
DEBUG io.micronaut.data.query - Executing Query: SELECT jdbc_product_.`uuid`,jdbc_product_.`merchant_id`,jdbc_product_.`name`,jdbc_product_.`category_id` FROM `PRODUCT` jdbc_product_ WHERE (jdbc_product_.`category_id` = ? AND jdbc_product_.`name` LIKE CONCAT('%',?,'%') AND jdbc_product_.`merchant_id` = ?)
TRACE io.micronaut.data.query - Binding parameter at position 1 to value 98ca0372-4169-4241-aab3-1519956071d7 with data type: UUID
TRACE io.micronaut.data.query - Binding parameter at position 2 to value Car with data type: STRING
...
TRACE io.micronaut.data.query - Binding parameter at position 3 to value afe252cb-3daf-457d-b9e3-e2759e707a0e with data type: UUID
And this is how we can support multiple tenants in our Micronaut application, without explicitly handling the tenant ids in the source code.
Final words
As always, no solution is perfect for every use case. The design I’m proposing here includes several ideas that have been tested in real-world projects under specific conditions:
-
Applications built to evolve over time, with an architecture that supports structural changes without breaking the code.
-
Teams with many developers who need to collaborate and coordinate changes across different layers of the architecture.
Some of these suggestions also reflect my personal preferences, which may not be the best fit for everyone. Still, I hope you find them inspiring for your own projects.