DEV Community

Cover image for An Elegant Way to Solve Multi-Tenancy
Bernd Stübinger for MediaMarktSaturn Technology

Posted on • Edited on

An Elegant Way to Solve Multi-Tenancy

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() }
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    ...
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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) }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    ...
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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}"
    ...
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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) {
        ...
    }
}

Enter fullscreen mode Exit fullscreen mode

Tell me what you think and especially, if you see even more potential for improving!


get to know us 👉 https://mms.tech 👈

Top comments (0)