Managing multi-tenant applications with Micronaut’s multi-tenancy support is a game-changer for developers looking to build scalable and efficient software. I’ve been working with Micronaut for a while now, and I must say, its multi-tenancy features are pretty impressive.
Let’s start with the basics. Multi-tenancy is an architecture where a single instance of software serves multiple customers or “tenants.” Each tenant’s data is isolated and remains invisible to other tenants. It’s like having separate apartments in one building – everyone has their own space, but they share the same infrastructure.
Micronaut makes implementing multi-tenancy a breeze. It offers three main strategies: subdomain, schema, and discriminator. Each has its own use case, and I’ll walk you through them.
The subdomain strategy is probably the most common. It uses the subdomain of the incoming request to identify the tenant. For example, tenant1.myapp.com and tenant2.myapp.com would be two different tenants. Here’s how you can set it up:
@Controller("/hello")
class HelloController {
@Produces(MediaType.TEXT_PLAIN)
@Get
String hello(@CurrentTenant String tenant) {
return "Hello, " + tenant + "!";
}
}
In this example, Micronaut automatically extracts the tenant from the subdomain and injects it into the @CurrentTenant
parameter. Pretty neat, right?
The schema strategy is useful when you’re working with databases that support schemas. Each tenant gets its own schema, keeping data separate. Here’s how you might configure it:
micronaut:
multitenancy:
tenantresolver:
subdomain:
enabled: true
datasources:
default:
schema-generate: CREATE_DROP
dialect: POSTGRES
This YAML configuration tells Micronaut to use the subdomain resolver and sets up a default datasource with schema generation.
The discriminator strategy is more flexible. It allows you to determine the tenant based on any part of the request – a header, a query parameter, or even the request body. Here’s an example using a header:
@Singleton
class HeaderTenantResolver implements TenantResolver {
@Override
public Mono<String> resolveTenantIdentifier() {
return Mono.deferContextual(ctx ->
Mono.justOrEmpty(ctx.getOrEmpty(HttpRequest.class)
.flatMap(request ->
Optional.ofNullable(request.getHeaders().get("X-Tenant-ID"))
)
)
);
}
}
This resolver looks for a custom header X-Tenant-ID
to determine the tenant. You’d then need to register this resolver in your application configuration.
Now, let’s talk about data isolation. It’s crucial to ensure that one tenant can’t access another tenant’s data. Micronaut helps with this by automatically adding tenant information to database queries. For example:
@Repository
interface UserRepository extends CrudRepository<User, Long> {
@Query("SELECT * FROM users WHERE tenant_id = :tenantId")
List<User> findAllForTenant(@Parameter("tenantId") String tenantId);
}
Micronaut will automatically inject the current tenant ID into this query, ensuring data isolation.
But what about shared data? Sometimes you want certain information to be accessible across all tenants. Micronaut has a solution for that too. You can use the @TenantAware
annotation to specify which fields in your entity are tenant-specific:
@Entity
class Product {
@Id
@GeneratedValue
Long id;
@TenantAware
String tenantId;
String name;
BigDecimal price;
}
In this case, the tenantId
field is tenant-aware, but name
and price
are shared across all tenants.
Let’s dive a bit deeper into tenant-specific configuration. Sometimes, you want different settings for different tenants. Micronaut allows you to do this easily:
micronaut:
multitenancy:
tenants:
tenant1:
datasources:
default:
url: jdbc:mysql://localhost/tenant1_db
tenant2:
datasources:
default:
url: jdbc:mysql://localhost/tenant2_db
This configuration sets up different database connections for each tenant. Micronaut will automatically use the correct connection based on the current tenant.
Now, let’s talk about testing. Multi-tenant applications can be tricky to test, but Micronaut makes it easier. You can use the @Property
annotation to set the tenant for your tests:
@MicronautTest
@Property(name = "micronaut.multitenancy.tenantresolver.httpheader.enabled", value = "true")
@Property(name = "micronaut.multitenancy.tenantresolver.httpheader.header-name", value = "X-Tenant-ID")
class MultiTenantTest {
@Inject
EmbeddedServer embeddedServer;
@Test
void testMultiTenancy() {
HttpClient client = embeddedServer.getApplicationContext().createBean(HttpClient.class, embeddedServer.getURL());
String response = client.toBlocking().retrieve(
HttpRequest.GET("/hello").header("X-Tenant-ID", "tenant1")
);
assertEquals("Hello, tenant1!", response);
}
}
This test sets up a mock HTTP request with a tenant header and verifies that the application responds correctly.
One thing I’ve learned from working with multi-tenant applications is the importance of proper error handling. What happens if a tenant doesn’t exist, or if there’s an error switching between tenants? Micronaut allows you to create custom exception handlers for these scenarios:
@Singleton
@Requires(classes = TenantNotFoundException.class)
class TenantNotFoundExceptionHandler implements ExceptionHandler<TenantNotFoundException, HttpResponse> {
@Override
public HttpResponse handle(HttpRequest request, TenantNotFoundException exception) {
return HttpResponse.notFound("Tenant not found");
}
}
This handler will catch any TenantNotFoundException
and return a 404 response.
Security is another crucial aspect of multi-tenant applications. You need to ensure that users from one tenant can’t access data from another tenant. Micronaut integrates well with security frameworks like Spring Security. Here’s a basic setup:
@Singleton
class SecurityConfig extends AbstractSecurityConfiguration {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/api/**").authenticated()
.and()
.tenantSecurity()
.validateTenant(true);
}
}
This configuration ensures that all API endpoints require authentication and that the tenant is validated for each request.
Performance is always a concern with multi-tenant applications. One trick I’ve found useful is to use caching, but with tenant-aware cache keys. Micronaut’s caching support makes this easy:
@Singleton
class ProductService {
@Cacheable(value = "products", key = "#tenantId + '-' + #productId")
Optional<Product> getProduct(String tenantId, Long productId) {
// Fetch product from database
}
}
This ensures that cached data is always scoped to the correct tenant.
As your multi-tenant application grows, you might find that some tenants require custom business logic. Micronaut’s dependency injection system is perfect for handling this. You can create tenant-specific beans:
@Singleton
@Requires(property = "tenant.name", value = "premium")
class PremiumPricingService implements PricingService {
// Premium pricing logic
}
@Singleton
@Requires(property = "tenant.name", notEquals = "premium")
class StandardPricingService implements PricingService {
// Standard pricing logic
}
Micronaut will automatically inject the correct service based on the current tenant’s configuration.
One challenge you might face is reporting across tenants. Sometimes you need to aggregate data from multiple tenants. Here’s a pattern I’ve used successfully:
@Singleton
class CrossTenantReportService {
@Inject
TenantResolver tenantResolver;
@Inject
ProductRepository productRepository;
public Map<String, Long> getProductCountByTenant() {
return tenantResolver.getAllTenants().stream()
.collect(Collectors.toMap(
tenant -> tenant,
tenant -> productRepository.countByTenantId(tenant)
));
}
}
This service iterates over all tenants and collects data from each one.
Monitoring and logging are essential for any application, but they’re especially important in a multi-tenant environment. You want to be able to trace issues back to specific tenants. Micronaut integrates well with various logging frameworks. Here’s an example using Logback:
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{tenantId}] - %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="STDOUT" />
</root>
</configuration>
This configuration includes the tenant ID in each log message, making it easy to filter logs by tenant.
As you can see, Micronaut provides a robust set of tools for building multi-tenant applications. From tenant resolution to data isolation, from configuration to testing, it’s got you covered. But remember, with great power comes great responsibility. Multi-tenancy adds complexity to your application, so always keep security and data isolation at the forefront of your mind.
In my experience, the key to success with multi-tenant applications is to start simple and gradually add complexity as needed. Begin with a basic tenant resolution strategy and a single shared database. As your application grows and your tenants’ needs diverge, you can introduce more sophisticated isolation techniques.
Micronaut’s multi-tenancy support has saved me countless hours of development time. It’s allowed me to focus on building features for my users rather than reinventing the wheel with tenant management. Whether you’re building a SaaS application or just need to separate data for different clients, Micronaut’s multi-tenancy support is definitely worth exploring.