docs/architecture/legacy-module-integration.md
This document outlines how existing legacy code is integrated into the new modular architecture of the application and the strategy for its migration. The core principle is to isolate legacy code and provide a controlled way for newer modules to interact with legacy functionality without becoming directly dependent on it.
[!NOTE]
This document should be read in conjunction with Module Structure and Module Organization to get a complete understanding of the modular architecture.
The Thunderbird for Android project is transitioning from a monolithic architecture to a modular one. During this
transition, we need to maintain compatibility with existing legacy code while gradually migrating to the new
architecture. The legacy:*, mail:*, and backend:* modules contain functionality that is still essential for the
project but does not yet adhere to the new modular architecture. These modules are integrated into the new architecture
through the :app-common module, which acts as a bridge or adapter to provide access to legacy functionality without
directly depending on it.
The key components in this integration strategy are:
legacy:*, mail:*, and backend:* modules containing existing functionalityfeature:*:api and core:* modules:app-common module that implements these interfaces and delegates to legacy codeNewer application modules (such as features or core components) depend on well-defined Interfaces
(e.g., those found in feature:*:api modules). Typically, a feature will provide its own modern Implementation
(e.g., :feature:mail:internal) for its API.
However, to manage dependencies on code still within legacy:*, mail:*, and backend:* modules and prevent it
from spreading, we use app-common as bridge or adapter to provide an alternative implementation for these. In
this role, app-common is responsible for:
app-common provides concrete implementations for interfaces that newer modules define.app-common implementations will delegate calls, adapt data, and manage interactions with the underlying legacy:*, mail:*, and backend:* modules.app-common.This approach ensures that:
internal or the app-common bridge.app-common bridge. As new, native implementations in feature modules (e.g., :feature:mail:internal) mature, the dependency injection can be switched to use them, often without changes to the modules consuming the interface.The typical flow is:
api module of a feature (e.g., :feature:mail:api) or a core module. These interfaces represent the contract for a piece of functionality.:feature:somefeature:internal or other parts of :app-common) depend on these defined interfaces, to avoid dependency on concrete legacy classes.:app-common module provides concrete implementations for these interfaces.:app-common delegate the actual work to the code residing in the legacy modules (e.g., legacy:*, mail:*, backend:*).:app-common bridge implementations when a newer module requests an implementation of the interface.This pattern ensures that newer modules remain decoupled from the specifics of legacy code.
The following diagram illustrates this pattern, showing how both a feature's own implementation and app-common can relate to the interfaces, with app-common specifically bridging to legacy systems:
graph TB
subgraph FEATURE[Feature Modules]
direction TB
INTERFACES["`**Interfaces**
(e.g., :feature:mail:api)`"]
IMPLEMENTATIONS["`**Implementations**
(e.g., :feature:mail:internal)`"]
OTHER_MODULES["`**Other Modules**
(depend on Interfaces)`"]
end
subgraph COMMON[App Common Module]
direction TB
COMMON_APP["`**:app-common**
Integration Code`"]
end
subgraph LEGACY[Legacy Modules]
direction TB
LEGACY_K9["`**:legacy**`"]
LEGACY_MAIL["`**:mail**`"]
LEGACY_BACKEND["`**:backend**`"]
end
OTHER_MODULES --> |uses| INTERFACES
IMPLEMENTATIONS --> |depends on| INTERFACES
COMMON_APP --> |implements| INTERFACES
COMMON_APP --> |delegates to / wraps| LEGACY_K9
COMMON_APP --> |delegates to / wraps| LEGACY_MAIL
COMMON_APP --> |delegates to / wraps| LEGACY_BACKEND
classDef common fill:#e6e6e6,stroke:#000000,color:#000000
classDef common_module fill:#999999,stroke:#000000,color:#000000
classDef feature fill:#d9ffd9,stroke:#000000,color:#000000
classDef feature_module fill:#33cc33,stroke:#000000,color:#000000
classDef legacy fill:#ffe6e6,stroke:#000000,color:#000000
classDef legacy_module fill:#ff9999,stroke:#000000,color:#000000
linkStyle default stroke:#999,stroke-width:2px
class COMMON common
class COMMON_APP common_module
class FEATURE feature
class INTERFACES,IMPLEMENTATIONS,OTHER_MODULES feature_module
class LEGACY legacy
class LEGACY_MAIL,LEGACY_BACKEND,LEGACY_K9 legacy_module
Several techniques are used to implement the bridge pattern effectively:
Wrapper Classes: Creating immutable data classes that wrap legacy data structures, implementing interfaces from the new architecture. These wrappers should not contain conversion methods but should delegate this responsibility to specific mapper classes.
Adapter Implementations: Classes in :app-common that implement interfaces from the new architecture but delegate to legacy code internally.
Data Conversion: Dedicated mapper classes that handle mapping between legacy and new data structures, ensuring clean separation of concerns.
A concrete example of this pattern is the account profile bridge, which demonstrates a complete implementation of the bridge pattern across multiple layers:
AccountProfileRepository in feature:account:api defines the high-level contract for account profile managementAccountProfileLocalDataSource in feature:account:core defines the data access contractAccountProfile in feature:account:api is a clean, immutable data class that represents account profile information in the new architecture.DefaultAccountProfileRepository in feature:account:core implements the AccountProfileRepository interface and depends on AccountProfileLocalDataSource.DefaultAccountProfileLocalDataSource in app-common implements the AccountProfileLocalDataSource interface and serves as the bridge to legacy code.DefaultLegacyAccountWrapperManager to access legacy account data:
LegacyAccountWrapperManager in core:android:account defines the contract for legacy account accessLegacyAccountWrapper in core:android:account is an immutable wrapper around the legacy LegacyAccount classAccountProfile objects and legacy account data.appCommonAccountModule in app-common registers DefaultAccountProfileLocalDataSource as implementations of the respective interface.This multi-layered approach allows newer modules to interact with legacy account functionality through clean, modern interfaces without directly depending on legacy code. It also demonstrates how bridges can be composed, with higher-level bridges (AccountProfile) building on lower-level bridges (LegacyAccountWrapper).
Testing bridge implementations requires special attention to ensure both the bridge itself and its integration with legacy code work correctly:
FakeLegacyAccountWrapperManager can be used to test components that depend on LegacyAccountWrapperManagerBelow are examples of tests for legacy module integration, demonstrating different testing approaches and best practices.
This example shows how to test a bridge implementation (DefaultAccountProfileLocalDataSource) in isolation by using a fake implementation of the legacy dependency (FakeLegacyAccountWrapperManager):
class DefaultAccountProfileLocalDataSourceTest {
@Test
fun `getById should return account profile`() = runTest {
// arrange
val accountId = AccountIdFactory.create()
val legacyAccount = createLegacyAccount(accountId)
val accountProfile = createAccountProfile(accountId)
val testSubject = createTestSubject(legacyAccount)
// act & assert
testSubject.getById(accountId).test {
assertThat(awaitItem()).isEqualTo(accountProfile)
}
}
@Test
fun `getById should return null when account is not found`() = runTest {
// arrange
val accountId = AccountIdFactory.create()
val testSubject = createTestSubject(null)
// act & assert
testSubject.getById(accountId).test {
assertThat(awaitItem()).isEqualTo(null)
}
}
@Test
fun `update should save account profile`() = runTest {
// arrange
val accountId = AccountIdFactory.create()
val legacyAccount = createLegacyAccount(accountId)
val accountProfile = createAccountProfile(accountId)
val updatedName = "updatedName"
val updatedAccountProfile = accountProfile.copy(name = updatedName)
val testSubject = createTestSubject(legacyAccount)
// act & assert
testSubject.getById(accountId).test {
assertThat(awaitItem()).isEqualTo(accountProfile)
testSubject.update(updatedAccountProfile)
assertThat(awaitItem()).isEqualTo(updatedAccountProfile)
}
}
private fun createTestSubject(
legacyAccount: LegacyAccountWrapper?,
): DefaultAccountProfileLocalDataSource {
return DefaultAccountProfileLocalDataSource(
accountManager = FakeLegacyAccountWrapperManager(
initialAccounts = if (legacyAccount != null) {
listOf(legacyAccount)
} else {
emptyList()
},
),
dataMapper = DefaultAccountProfileDataMapper(
avatarMapper = DefaultAccountAvatarDataMapper(),
),
)
}
}
Key points:
This example shows how to create a fake implementation of a legacy dependency (FakeLegacyAccountWrapperManager) for testing:
internal class FakeLegacyAccountWrapperManager(
initialAccounts: List<LegacyAccountWrapper> = emptyList(),
) : LegacyAccountWrapperManager {
private val accountsState = MutableStateFlow(
initialAccounts,
)
private val accounts: StateFlow<List<LegacyAccountWrapper>> = accountsState
override fun getAll(): Flow<List<LegacyAccountWrapper>> = accounts
override fun getById(id: AccountId): Flow<LegacyAccountWrapper?> = accounts
.map { list ->
list.find { it.id == id }
}
override suspend fun update(account: LegacyAccountWrapper) {
accountsState.update { currentList ->
currentList.toMutableList().apply {
removeIf { it.uuid == account.uuid }
add(account)
}
}
}
}
Key points:
This example shows how to test data conversion logic in bridge implementations:
class DefaultAccountProfileDataMapperTest {
@Test
fun `toDomain should convert ProfileDto to AccountProfile`() {
// Arrange
val dto = createProfileDto()
val expected = createAccountProfile()
val testSubject = DefaultAccountProfileDataMapper(
avatarMapper = FakeAccountAvatarDataMapper(
dto = dto.avatar,
domain = expected.avatar,
),
)
// Act
val result = testSubject.toDomain(dto)
// Assert
assertThat(result.id).isEqualTo(expected.id)
assertThat(result.name).isEqualTo(expected.name)
assertThat(result.color).isEqualTo(expected.color)
assertThat(result.avatar).isEqualTo(expected.avatar)
}
@Test
fun `toDto should convert AccountProfile to ProfileDto`() {
// Arrange
val domain = createAccountProfile()
val expected = createProfileDto()
val testSubject = DefaultAccountProfileDataMapper(
avatarMapper = FakeAccountAvatarDataMapper(
dto = expected.avatar,
domain = domain.avatar,
),
)
// Act
val result = testSubject.toDto(domain)
// Assert
assertThat(result.id).isEqualTo(expected.id)
assertThat(result.name).isEqualTo(expected.name)
assertThat(result.color).isEqualTo(expected.color)
assertThat(result.avatar).isEqualTo(expected.avatar)
}
}
Key points:
The long-term strategy involves gradually migrating functionality out of the legacy modules:
api modules) for this functionality.internal modules or core modules.:app-common was bridging to this specific legacy code, its bridge implementation can be updated or removed.Using the account profile example, the migration process would look like:
AccountProfileRepository interface is defined in feature:account:apiAccountProfileLocalDataSource interface is defined in feature:account:coreAccountProfile as an immutable data class in feature:account:api.AccountProfileLocalDataSource in a modern module, e.g., feature:account:internal.DefaultAccountProfileLocalDataSource in app-common.appCommonAccountModule to provide the new implementation instead of DefaultAccountProfileLocalDataSource.LegacyAccountWrapperManager, DefaultLegacyAccountWrapperManager) can be safely deleted.This approach ensures a smooth transition with minimal disruption to the application's functionality.
A strict dependency rule is enforced: New modules (features, core) must not directly depend on legacy modules.
The dependency flow is always from newer modules to interfaces, with :app-common providing the implementation.
If :app-common bridges to legacy code, that is an internal detail of :app-common.
The legacy module integration diagram below explains how legacy code is integrated into the new modular architecture:
graph TB
subgraph APP[App Modules]
direction TB
APP_TB["`**:app-thunderbird**
Thunderbird for Android`"]
APP_K9["`**:app-k9mail**
K-9 Mail`"]
end
subgraph COMMON[App Common Module]
direction TB
COMMON_APP["`**:app-common**
Integration Code`"]
end
subgraph FEATURE[Feature Modules]
direction TB
FEATURE1[Feature 1]
FEATURE2[Feature 2]
FEATURE3[Feature from Legacy]
end
subgraph CORE[Core Modules]
direction TB
CORE1[Core 1]
CORE2[Core 2]
CORE3[Core from Legacy]
end
subgraph LIBRARY[Library Modules]
direction TB
LIB1[Library 1]
LIB2[Library 2]
end
subgraph LEGACY[Legacy Modules]
direction TB
LEGACY_CODE[Legacy Code]
end
APP_K9 --> |depends on| COMMON_APP
APP_TB --> |depends on| COMMON_APP
COMMON_APP --> |integrates| FEATURE1
COMMON_APP --> |integrates| FEATURE2
COMMON_APP --> |integrates| FEATURE3
FEATURE1 --> |uses| CORE1
FEATURE1 --> |uses| LIB2
FEATURE2 --> |uses| CORE2
FEATURE2 --> |uses| CORE3
COMMON_APP --> |integrates| LEGACY_CODE
LEGACY_CODE -.-> |migrate to| FEATURE3
LEGACY_CODE -.-> |migrate to| CORE3
classDef app fill:#d9e9ff,stroke:#000000,color:#000000
classDef app_module fill:#4d94ff,stroke:#000000,color:#000000
classDef common fill:#e6e6e6,stroke:#000000,color:#000000
classDef common_module fill:#999999,stroke:#000000,color:#000000
classDef feature fill:#d9ffd9,stroke:#000000,color:#000000
classDef feature_module fill:#33cc33,stroke:#000000,color:#000000
classDef core fill:#e6cce6,stroke:#000000,color:#000000
classDef core_module fill:#cc99cc,stroke:#000000,color:#000000
classDef library fill:#fff0d0,stroke:#000000,color:#000000
classDef library_module fill:#ffaa33,stroke:#000000,color:#000000
classDef legacy fill:#ffe6e6,stroke:#000000,color:#000000
classDef legacy_module fill:#ff9999,stroke:#000000,color:#000000
linkStyle default stroke:#999,stroke-width:2px
class APP app
class APP_K9,APP_TB app_module
class COMMON common
class COMMON_APP common_module
class FEATURE feature
class FEATURE1,FEATURE2,FEATURE3 feature_module
class CORE core
class CORE1,CORE2,CORE3 core_module
class LIBRARY library
class LIB1,LIB2 library_module
class LEGACY legacy
class LEGACY_CODE legacy_module