How We Manage White-label Integrations in Runtime
Agoda has long been at the forefront of connecting travelers worldwide with an extensive array of accommodations, flights, and travel experiences. As part of our continuous effort to innovate and expand our offerings, we decided to white-label our website.
In a previous blog, we shared how we built and scaled different travel websites with one codebase. This approach has been instrumental in our ability to rapidly deploy tailored offerings across different brands, ensuring a consistent, high-quality experience for users worldwide. However, a significant challenge in this process is the integration with the white-label APIs for accessing customer databases.
In this blog, we share how we leveraged hexagonal architecture to streamline our white-label API integrations. By the end of this blog, you will have a clear understanding of hexagonal architecture, how it works on the code level, application, and how to manage beans in runtime with multiple integrations.
The Problem
One of the primary challenges in white-label integration is offering diverse services through a unified application. Imagine accommodating different partners, each with unique APIs for loyalty operations like search, verify, burn, and refund points. This variety requires a flexible system that can integrate various functionalities seamlessly without compromising on service quality.
Functional Requirements:
- Support each white-label process of searching & verifying customers.
- Authentication and Authorization when customers manage their points on the white-label side.
Non-functional Requirements:
- Easy to introduce new white-label in the system.
- Easy to remove white-label if needed.
- Can deploy certain white-label separately if needed.
- Scalability.
Designing the Solution: Hexagonal Architecture
To address these challenges, we require a high level of abstraction to adapt to different search and authentication flows and a unification framework to consolidate all white-label operations under one system’s APIs.
We chose hexagonal architecture for its scalability and adaptability. This architecture visualizes the application as a hexagon, where each side represents an interaction point with external services or users. It effectively separates the core logic from external interfaces, ensuring that changes in one part don’t necessitate widespread adjustments.
Hexagonal Architecture Overview
Hexagonal Architecture, also known as Ports and Adapters Architecture, is a software architecture pattern that aims to create a loosely coupled system, making it easier to maintain, test, and adapt to changes in technology or business requirements. Alistair Cockburn introduced this approach and emphasized the separation of concerns between the core logic of the application and the services it interacts with, such as databases, web interfaces, and third-party services.
Core Concepts
The core idea behind Hexagonal Architecture is to visualize the application as a hexagon, where each side (or port) represents a point of interaction with external actors or systems (these could be users, external services, databases, etc.). Each port has one or more adapters that convert the external requests into a format the application can understand and vice versa.
Ports:
- Primary (Driving) Ports: These are the interfaces through which the core application receives commands or data from external actors (e.g., HTTP requests from a web UI and API calls).
- Secondary (Driven) Ports: These interfaces allow the application to interact with external resources or services (e.g., database access, external API requests).
Adapters:
- Primary (Driving) Adapters: They adapt the input from an external actor to the application, invoking the core logic through a primary port.
- Secondary (Driven) Adapters: Adapt the core application’s requests to external services or resources through the secondary ports.
Advantages of Hexagonal Architecture
- Flexibility: Since external dependencies are interfaced through ports and adapters, replacing or modifying them without impacting the core application logic is straightforward.
- Testability: The separation allows for easier testing of the core logic by mocking the external interfaces and facilitating unit and integration testing.
- Maintainability: Business logic or technology changes affect only specific parts of the architecture, minimizing widespread impact.
- Scalability: Different parts of the system can be scaled or optimized independently based on their specific requirements.
Implementing the Solution
Now, let’s explore how to implement this approach to abstract loyalty APIs within the domain layer, where each white label is equipped with its own adapter. Additionally, we can have different build flavors by packaging each single white label with the common adapters (DB, Agoda APIs, App APIs) that solves our nonfunctional requirements. A key advantage of this architecture is the ability to test each white label separately. But how do we ensure that each request is auto-wired to the correct white-label implementation for that request?
Managing Dependency Injection Context
We cannot simply place all these beans in the app and expect that to work, as each loyalty feature has multiple implementations. So, to handle this situation, we need to split the context into sub-contexts; each sub-context represents a white label. All dependency injection framework allows the creation of sub-contexts (Spring, Koin, …) that help auto-wiring the right bean and its dependencies for the request. Each zone can also have its own cache and config beans. Each request has a white label id in the headers; now, we can select the sub context by id and get the bean to handle the request. App Injection Context
Let’s see this example using Kotlin & Koin:
In the parent context, we define sub context; each has its id.
object ServerSetup {
fun appContext():List<Module> {
val sharedModules = listOf(DBSetup.dbSetup())
val parentModule = module {
single<KoinApplication>(named("51")) {
WhiteLabelXSetup.setup(sharedModules)
}
single<KoinApplication>(named("52")) {
WhiteLabelYSetup.setup(sharedModules)
}
single<KoinApplication>(named("53")) {
WhitelabelZSetup.setup(sharedModules)
}
}
return sharedModules.plus(parentModule)
}
}
Now let’s see what each sub-context looks like:
object WhiteLabelXSetup {
fun setup(sharedModules: List<Module>): KoinApplication {
val xAppContext = module {
single<SearchCustomerValidator> { XSearchCustomerValidatorImpl() }
single<SearchCustomerHandler> { XSearchCustomerHandlerImpl(get()) }
}
val appModules = sharedModules.plus(xAppContext)
return koinApplication {
modules(appModules)
}
}
}
object WhiteLabelYSetup {
fun setup(sharedModules: List<Module>): KoinApplication {
val xAppContext = module {
single<SearchCustomerValidator> { YSearchCustomerValidatorImpl() }
single<SearchCustomerHandler> { YSearchCustomerHandlerImpl(get()) }
}
val appModules = sharedModules.plus(xAppContext)
return koinApplication {
modules(appModules)
}
}
}
object WhitelabelZSetup {
fun setup(sharedModules: List<Module>): KoinApplication {
val xAppContext = module {
single<SearchCustomerValidator> { ZSearchCustomerValidatorImpl() }
single<SearchCustomerHandler> { ZSearchCustomerHandlerImpl(get()) }
}
val appModules = sharedModules.plus(xAppContext)
return koinApplication {
modules(appModules)
}
}
}
Now let’s see how to get the right sub-context:
We get the sub context using the header and use the injector of the sub context to get the bean.
fun Routing.loyaltyRoutes(parentApp: KoinApplication) {
route("customer/") {
post("search") {
val whiteLabelId =
call.request.headers.get("whitelabel-id") ?: throw Exception("Bad Request missing whitelabel header")
val subContextInjector = parentApp.koin.get<KoinApplication>(qualifier(whiteLabelId)).koin
val searchCustomerHandler = subContextInjector.get<SearchCustomerHandler>()
call.receive<SearchCustomerRequest>().let { request ->
call.respond(
HttpStatusCode.OK,
searchCustomerHandler.handle(request)
)
}
}
}
}
How to ensure the right config for auto-wiring in the zones
To ensure that no developer makes a mistake and wires the wrong bean in the config, well for that you have two options:
- Module per white-label adapter: that is the easy option but will not help in the long run when you have many white labels.
- Package per white label, and to ensure the separation, you will need an architecture testing framework to ensure that there is no wrong usage in different zones. The analysis result should be:
- White-label X depends on Core beans.
- White-label Y depends on Core beans.
- White-label Z depends on Core beans.
- No dependency between white-label adapters and each other.
Let’s see how this is done using Konsist:
First, let’s assert the architecture.
Konsist
.scopeFromProject().assertArchitecture{
val core = Layer("Core", "com.agoda.loyalty.core..")
val api = Layer("Presentation", "com.agoda.loyalty.api..")
val data = Layer("Data", "com.agoda.loyalty.db..")
val whitelabels = Layer("Whitelabels", "com.agoda.loyalty.whitelabels..")
// Define architecture assertions
core.dependsOnNothing()
api.dependsOn(core)
data.dependsOn(core)
whitelabels.dependsOn(core)
}
Now, let’s ensure there is no dependency between white label adapters and each other.
val whitelabelPackages =
Konsist.scopeFromProject()
.packages
.filter { it.fullyQualifiedName.contains("com.agoda.loyalty.whitelabels.") }
.groupBy { it.fullyQualifiedName }.keys.toList()
whitelabelPackages.forEach { current ->
val otherWhiteLabelsPackages = whitelabelPackages.filter { it != current }.map { "${it}.." }
val currentWhiteLabel = Konsist
.scopeFromProject()
.files
.withPackage("${current}..")
otherWhiteLabelsPackages.forEach { otherWhiteLabelsPackage ->
currentWhiteLabel.assertFalse { currentWhiteLabelRule ->
currentWhiteLabelRule.hasImportWithName(otherWhiteLabelsPackage)
}
}
}
Testing
In such a complex project, you can’t just run all tests for all white labels, so we need to minimize the number of tests to run to save development time; this can be done by checking the changed modules and running the tests in it.
If the common modules are changed, you will run all tests, but if you are introducing a new white label, you want to run that adapter test. with a simple Git command plus a Linux command. You can just know the changed adapter.
CHANGED_MODULES=$({ git diff main..feature --name-only db api core src| sed
s'/\/.*//' ; git diff main..feature --name-only -- whitelabels | sed -E
's/.*whitelabels\/([^\/]+).*/\1/'; } | sed s'/\/.*//' | uniq | sed
'H;1h;$!d;x;y/\n/,/')
Now, you can configure your build task to check if the module is in the list of changed modules.
For white-labels module tests:
val changedModules = System.getenv("CHANGED_MODULES").split(",")
val changedWhiteLabels = changedModules.filter { it != "api" && it != "db" && it != "core" }
tasks.test {
onlyIf {
changedWhiteLabels.isNotEmpty()
}
filter {
changedWhiteLabels.forEach {
includeTestsMatching("com.agoda.loyalty.whitelabels.$it")
}
includeTestsMatching("com.agoda.loyalty.ArchitectureTest")
isFailOnNoMatchingTests = false
}
testLogging {
events("skipped", "failed", "passed")
showStackTraces = true
exceptionFormat = TestExceptionFormat.FULL
}
}
For any module except white labels & root:
val changedModules = System.getenv("CHANGED_MODULES").split(",")
tasks.test {
useJUnitPlatform {}
onlyIf {
changedModules.contains(project.name)
}
testLogging {
events("skipped", "failed", "passed")
showStackTraces = true
exceptionFormat = TestExceptionFormat.FULL
}
}
For root project test:
val changedModules = System.getenv("CHANGED_MODULES").split(",")
tasks.test {
useJUnitPlatform {}
onlyIf {
changedModules.contains("src")
}
testLogging {
events("skipped", "failed", "passed")
showStackTraces = true
exceptionFormat = TestExceptionFormat.FULL
}
}
Conclusion
We’ve explored the benefits of using hexagonal architecture to manage white-label integrations, handling and splitting dependency injection context, and how these practices enhance development efficiency and service delivery to different partners.
For a detailed look at the implementation, visit our full sample on GitHub: White-label Integration Sample