An Elegant Way to Solve Multi-Tenancy
Bernd Stübinger
Posted on January 30, 2024
In this article, I will show you an elegant way to introduce multi-tenancy in an already established code base for a Kotlin/Koin stack. And while the examples are specific to this stack, the general idea should be applicable to any dependency injection framework that supports qualifiers.
History
Our product initially started below the radar to support the migration of MediaMarktSaturn's webshop to the new platform. Back then, we had to replace an existing legacy promotion system by the beginning of the Black Friday season, which meant that we already had a fixed (business) scope and a fixed deadline.
Naturally, we had to cut short on some of the more technical aspects of our code base. One of that aspects was multi-tenancy, in our case different countries and sales lines - Media Markt Germany would be a different tenant than Saturn Germany or Media Markt Austria.
Of course, we knew that we had to deal with multiple tenants at some point but since the whole migration targeted mediamarkt.de only, we could easily postpone that topic until after the first go-live. We already included the relevant parts in our data model and in our API, though, so that we wouldn't have to perform a data migration and consumers of our system could keep their existing integrations.
For our implementation we used the much easier "ignorance" strategy, i.e. we didn't deal with tenants anywhere - and in the (few) places where we needed to work with a tenant, we simply hardcoded it to Media Markt Germany.
Initial Setup
We are using a pure Kotlin reactive stack with Ktor as web framework and Koin for dependency injection and application configuration. Our product manages coupons and calculates discounts for customers, i.e assesses their baskets with respect to pre-configured promotion rules.
In its essence, our setup looked like this:
val serviceModule = module {
single { OutletRepository() }
single { CalculationService() }
single { PromotionRepository() }
single { CouponRepository() }
}
You can think of a module
as a space to collect all your Koin-managed components, similar to "beans" in Spring or CDI. single
declares a singleton component that will be instantiated only once and re-used for every injection point.
In our routing layer we receive basket assessment requests and forward them to our main service, the CalculationService
:
routing {
val calculationService: CalculationService = get()
get("basket-assessments") {
val request = call.receive<CalculationRequest>()
call.respond(calculationService.calculateBasket(request))
}
}
The CalculationService
then fetches active promotions from the PromotionRepository
and applies coupons using a CouponRepository
. Naturally, this setup has different requirements to multi-tenancy: While coupons and active promotions differ between country and sales line, the core calculation logic will always stay the same and also outlet master data will be identical across all tenants. Coupons are identified by their code and thus even need to be separated on persistence level, so that multiple tenants can use identical codes.
Inside our CalculationService
we use get
to let Koin eagerly resolve and inject the managed instance(s) of other services:
class CalculationService {
private val promotionRepository: PromotionRepository = get()
private val couponRepository: CouponRepository = get()
private val outletRepository: OutletRepository = get()
...
}
From Simple...
When we eventually added support for multi-tenancy, we already had an established code base, so we were facing a few challenges:
- We couldn't afford (and didn't want) to rebuild everything from scratch
- We had lots of functions that required to know which tenant they were working with
- We also had lots of tests that didn't know anything about tenants yet
So we were looking for a solution that would allow us to keep as much of our current implementation as possible while at the same time didn't force us to introduce changes everywhere. We decided to separate all data on database level already so that our tenants could work independently from each other - I've already mentioned coupon codes above, but this can be seen as a good practice in general.
The simple approach was to determine a tenant from the request, and then add a tenant
parameter to each subsequent function call. Of course, that wasn't exactly... elegant.
We had some places that experimented with the CoroutineContext
to transfer tenant information but there were still a lot of places (including tests) that needed to be adapted, and back then we were just starting to understand how coroutines work. Also, we had to rely on the presence of a tenant during runtime, and actually wanted to have a bit more compile-time safety.
...to Elegant
So instead, we opted for "tenant-aware" services. We divided our existing service implementations into those that would provide common functionality and those that needed a tenant. For the latter we introduced a TenantAware
interface with a single property:
interface TenantAware {
val tenant: Tenant
}
All services that needed a tenant would now implement this interface and receive a tenant
in their constructor. So our module configuration changed to:
val serviceModule = module {
single { OutletRepository() }
SupportedTenant.values().forEach { tenant ->
single(named(tenant.id)) { CalculationService(tenant) }
single(named(tenant.id)) { PromotionRepository(tenant) }
single(named(tenant.id)) { CouponRepository(tenant) }
}
}
This way, we had e.g. an instance of PromotionRepository
for MediaMarkt Germany and one for Saturn Germany, and within that instance we had reliable access to the tenant
property whenever we needed it - for example, when referring to other tenant-aware services that would now resolve the tenant-specific instance from Koin:
class CalculationService(override val tenant: Tenant) : TenantAware {
private val promotionRepository: PromotionRepository = get(named(tenant.id))
private val couponRepository: CouponRepository = get(named(tenant.id))
private val outletRepository: OutletRepository = get()
...
}
This allowed us to limit all new tenant functionality to our routing layer, and we didn't have to change anything in our business code because every tenant-aware instance would only ever talk to other instances for the same tenant:
routing {
get("basket-assessments") {
val request = call.receive<CalculationRequest>()
val calculationService = get<CalculationService>(named(request.tenant.id))
call.respond(calculationService.calculateBasket(request))
}
}
And by adding a tenant suffix to our database collection (we are using MongoDB) we could easily achieve data separation in our persistence as well:
class PromotionRepository(override val tenant: Tenant) : TenantAware {
val collectionName = "promotions.${tenant.id}"
...
}
In our tests we only had to adapt our injection points and didn't need to touch any of the actual test methods - voila! If needed, we could easily test multiple tenants by simply injecting different instances of a service.
Extension Functions
As last polish, we used Kotlin's extension functions to simplify our module definition and dependency resolution:
val Tenant.qualifier
get() = named(id)
inline fun <reified T> Module.singleByTenant(noinline block: Scope.(Tenant) -> T) =
SupportedTenant.values().map { tenant ->
single(tenant.qualifier) { block(tenant) }
}
inline fun <reified T : Any> get(tenant: Tenant) = get<T>(tenant.qualifier)
val serviceModule = module {
single { OutletRepository() }
singleByTenant { CalculationService(it) }
singleByTenant { PromotionRepository(it) }
singleByTenant { CouponRepository(it) }
}
class CalculationService(override val tenant: Tenant) : TenantAware {
private val promotionRepository: PromotionRepository = get(tenant)
private val couponRepository: CouponRepository = get(tenant)
private val outletRepository: OutletRepository = get()
...
}
routing {
get("basket-assessments") {
val request = call.receive<CalculationRequest>()
val calculationService = get<CalculationService>(request.tenant)
call.respond(calculationService.calculateBasket(request))
}
}
During the initial design we spent some days tweaking our multi-tenancy implementation but since then we've had no issues and are still using it without major changes.
And with our meanwhile grown understanding of coroutines, we have also been able to use our TenantAware
interface for elegant tenant-aware authorization:
suspend inline fun <T> TenantAware.requireClaim(
claim: Claim,
block: () -> T
): T {
// Check if current user has required `claim` for current `tenant`
...
}
class CouponService(override val tenant: Tenant) : TenantAware {
fun createCoupon() = requireClaim(Claim.COUPON_CREATE) {
...
}
}
Tell me what you think and especially, if you see even more potential for improving!
get to know us 👉 https://mms.tech 👈
Posted on January 30, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
September 21, 2024