docs/architecture/README.md
The application follows a modular architecture with clear separation between different layers and components. The architecture is designed to support both the Thunderbird for Android and K-9 Mail applications while maximizing code reuse, maintainability and enable adoption of Kotlin Multiplatform in the future.
The Architecture Decision Records document the architectural decisions made during the development of the project, providing context and rationale for key technical choices. Reading through these decisions will improve your contributions and ensure long-term maintainability of the project.
The application is organized into several module types:
app-thunderbird and app-k9mail - Application entry pointsapp-common - Shared code between applicationsfeature:* - Independent feature modulescore:* - Foundational components and utilities used across multiple featureslibrary:* - Specific implementations for reuseFor more details on the module organization and structure, see the Module Organization and Module Structure documents.
The architecture follows several key patterns to ensure maintainability, testability, and separation of concerns:
Each module should be split into two main parts: API and internal. This separation provides clear boundaries between what a module exposes to other modules and how it implements its functionality internally:
This separation provides clear boundaries, improves testability, and enables flexibility.
See API Module and Internal Module for more details.
Thunderbird for Android uses Clean Architecture with three main layers (UI, domain, and data) to break down complex feature implementation into manageable components. Each layer has a specific responsibility:
graph TD
subgraph UI[UI Layer]
UI_COMPONENTS[UI Components]
VIEW_MODEL[ViewModels]
end
subgraph DOMAIN["Domain Layer"]
USE_CASE[Use Cases]
REPO[Repositories]
end
subgraph DATA[Data Layer]
DATA_SOURCE[Data Sources]
API[API Clients]
DB[Local Database]
end
UI_COMPONENTS --> VIEW_MODEL
VIEW_MODEL --> USE_CASE
USE_CASE --> REPO
REPO --> DATA_SOURCE
DATA_SOURCE --> API
DATA_SOURCE --> DB
classDef ui_layer fill:#d9e9ff,stroke:#000000,color:#000000
classDef ui_class fill:#4d94ff,stroke:#000000,color:#000000
classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000
classDef domain_class fill:#33cc33,stroke:#000000,color:#000000
classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000
classDef data_class fill:#ffaa33,stroke:#000000,color:#000000
linkStyle default stroke:#999,stroke-width:2px
class UI ui_layer
class UI_COMPONENTS,VIEW_MODEL ui_class
class DOMAIN domain_layer
class USE_CASE,REPO domain_class
class DATA data_layer
class DATA_SOURCE,API,DB data_class
The UI layer is responsible for displaying data to the user and handling user interactions.
Key Components:
Pattern: Model-View-Intent (MVI)
The domain layer contains the business logic and rules of the application. It is independent of the UI and data layers, allowing for easy testing and reuse.
Key Components:
graph TB
subgraph DOMAIN[Domain Layer]
USE_CASE[Use Cases]
MODEL[Domain Models]
REPO_API[Repository Interfaces]
end
subgraph DATA[Data Layer]
REPO_IMPL[Repository Implementations]
end
USE_CASE --> |uses| REPO_API
USE_CASE --> |uses| MODEL
REPO_API --> |uses| MODEL
REPO_IMPL --> |implements| REPO_API
REPO_IMPL --> |uses| MODEL
classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000
classDef domain_class fill:#33cc33,stroke:#000000,color:#000000
classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000
classDef data_class fill:#ffaa33,stroke:#000000,color:#000000
linkStyle default stroke:#999,stroke-width:2px
class DOMAIN domain_layer
class USE_CASE,REPO_API,MODEL domain_class
class DATA data_layer
class REPO_IMPL data_class
The data layer is responsible for data retrieval, storage, and synchronization.
Key Components:
Pattern: Data Source Pattern
graph TD
subgraph DOMAIN[Domain Layer]
REPO_API[Repository]
end
subgraph DATA[Data Layer]
REPO_IMPL[Repository implementations]
RDS[Remote Data Sources]
LDS[Local Data Sources]
MAPPER[Data Mappers]
DTO[Data Transfer Objects]
end
REPO_IMPL --> |implements| REPO_API
REPO_IMPL --> RDS
REPO_IMPL --> LDS
REPO_IMPL --> MAPPER
RDS --> MAPPER
LDS --> MAPPER
MAPPER --> DTO
classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000
classDef domain_class fill:#33cc33,stroke:#000000,color:#000000
classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000
classDef data_class fill:#ffaa33,stroke:#000000,color:#000000
linkStyle default stroke:#999,stroke-width:2px
class DOMAIN domain_layer
class REPO_API domain_class
class DATA data_layer
class REPO_IMPL,RDS,LDS,MAPPER,DTO data_class
Immutability means that once an object is created, it cannot be changed. Instead of modifying existing objects, new objects are created with the desired changes. In the context of UI state, this means that each state object represents a complete snapshot of the UI at a specific point in time.
Why is Immutability Important?
Immutability provides several benefits:
The UI is built using Jetpack Compose with a component-based architecture following our modified Model-View-Intent (MVI) pattern. This architecture provides a unidirectional data flow, clear separation of concerns, and improved testability.
For detailed information about the UI architecture and theming, see the UI Architecture and Theme System documents.
The application implements an offline-first Approach to provide a reliable user experience regardless of network conditions:
graph LR
subgraph UI[UI Layer]
VIEW_MODEL[ViewModel]
end
subgraph DOMAIN[Domain Layer]
USE_CASE[Use Cases]
end
subgraph DATA[Data Layer]
subgraph SYNC[Synchronization]
SYNC_MANAGER[Sync Manager]
SYNC_QUEUE[Sync Queue]
end
REPO[Repository]
LOCAL[Local Data Source]
REMOTE[Remote Data Source]
end
VIEW_MODEL --> USE_CASE
USE_CASE --> REPO
SYNC_MANAGER --> LOCAL
SYNC_MANAGER --> REMOTE
SYNC_MANAGER --> SYNC_QUEUE
REPO --> LOCAL
REPO --> REMOTE
REPO --> SYNC_MANAGER
REPO ~~~ SYNC
classDef ui_layer fill:#d9e9ff,stroke:#000000,color:#000000
classDef ui_class fill:#4d94ff,stroke:#000000,color:#000000
classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000
classDef domain_class fill:#33cc33,stroke:#000000,color:#000000
classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000
classDef data_class fill:#ffaa33,stroke:#000000,color:#000000
classDef sync_layer fill:#e6cce6,stroke:#000000,color:#000000
classDef sync_class fill:#cc99cc,stroke:#000000,color:#000000
linkStyle default stroke:#999,stroke-width:2px
class UI ui_layer
class VIEW_MODEL ui_class
class DOMAIN domain_layer
class USE_CASE domain_class
class DATA data_layer
class REPO,LOCAL,REMOTE data_class
class SYNC sync_layer
class SYNC_MANAGER,SYNC_API,SYNC_QUEUE sync_class
The offline-first approach is implemented across all layers of the application:
The application uses Koin for dependency injection, with modules organized by feature:
// Example Koin module for a feature
val featureModule = module {
viewModel { FeatureViewModel(get()) }
single<FeatureRepository> { FeatureRepositoryImpl(get(), get()) }
single<FeatureUseCase> { FeatureUseCaseImpl(get()) }
single<FeatureApiClient> { FeatureApiClientImpl() }
}
Cross-cutting concerns are aspects of the application that affect multiple features and cannot be cleanly handled individually for every feature. These concerns require consistent implementation throughout the codebase to ensure maintainability an reliability.
In Thunderbird for Android, several cross-cutting concerns are implemented as dedicated core modules to provide standardized solutions that can be reused across the application:
core/outcome) transforms exceptions into domain-specific errors and provides user-friendly feedback.core/logging) ensures consistent log formatting, levels, and storage.core/security handle encryption, authentication, and secure data storage.Work in progress:
core/crypto module provides encryption and decryption utilities for secure data handling.core/feature-flags module manages feature toggles and experimental features.core/sync module manages background synchronization, conflict resolution, and offline-first behavior.By implementing these concerns as core modules, the application achieves a clean and modular architecture that is easier to maintain and extend.
The application implements a comprehensive error handling strategy across all layers. We favor using the Outcome pattern over exceptions for expected error conditions, while exceptions are reserved for truly exceptional situations that indicate programming errors or unrecoverable system failures.
[!NOTE]
Exceptions should be used sparingly. Favor the Outcome pattern and sealed classes for predictable error conditions to enhance maintainability and clarity.
When implementing error handling in your code:
Define domain-specific errors as sealed classes in your feature's domain layer:
sealed class AccountError {
data class AuthenticationFailed(val reason: String) : AccountError()
data class NetworkError(val exception: Exception) : AccountError()
data class ValidationError(val field: String, val message: String) : AccountError()
}
Use result patterns (Outcome) instead of exceptions for error handling:
// Use the Outcome class for representing success or failure
sealed class Outcome<out T, out E> {
data class Success<T>(val value: T) : Outcome<T, Nothing>()
data class Failure<E>(val error: E) : Outcome<Nothing, E>()
}
Transform external errors into domain errors in your repositories using result patterns:
// Return Outcome instead of throwing exceptions
fun authenticate(credentials: Credentials): Outcome<AuthResult, AccountError> {
return try {
val result = apiClient.authenticate(credentials)
Outcome.Success(result)
} catch (e: HttpException) {
val error = when (e.code()) {
401 -> AccountError.AuthenticationFailed("Invalid credentials")
else -> AccountError.NetworkError(e)
}
logger.error(e) { "Authentication failed: ${error::class.simpleName}" }
Outcome.Failure(error)
} catch (e: Exception) {
logger.error(e) { "Authentication failed with unexpected error" }
Outcome.Failure(AccountError.NetworkError(e))
}
}
Handle errors in Use Cases by propagating the Outcome:
class LoginUseCase(
private val accountRepository: AccountRepository,
private val credentialValidator: CredentialValidator,
) {
fun execute(credentials: Credentials): Outcome<AuthResult, AccountError> {
// Validate input first
val validationResult = credentialValidator.validate(credentials)
if (validationResult is ValidationResult.Failure) {
return Outcome.Failure(
AccountError.ValidationError(
field = validationResult.field,
message = validationResult.message
)
)
}
// Proceed with authentication
return accountRepository.authenticate(credentials)
}
}
Handle outcomes in ViewModels and transform them into UI state:
viewModelScope.launch {
val outcome = loginUseCase.execute(credentials)
when (outcome) {
is Outcome.Success -> {
_uiState.update { it.copy(isLoggedIn = true) }
}
is Outcome.Failure -> {
val errorMessage = when (val error = outcome.error) {
is AccountError.AuthenticationFailed ->
stringProvider.getString(R.string.error_authentication_failed, error.reason)
is AccountError.NetworkError ->
stringProvider.getString(R.string.error_network, error.exception.message)
is AccountError.ValidationError ->
stringProvider.getString(R.string.error_validation, error.field, error.message)
}
_uiState.update { it.copy(error = errorMessage) }
}
}
}
Always log errors for debugging purposes:
// Logging is integrated into the Outcome pattern
fun fetchMessages(): Outcome<List<Message>, MessageError> {
return try {
val messages = messageService.fetchMessages()
logger.info { "Successfully fetched ${messages.size} messages" }
Outcome.Success(messages)
} catch (e: Exception) {
logger.error(e) { "Failed to fetch messages" }
Outcome.Failure(MessageError.FetchFailed(e))
}
}
Compose multiple operations that return Outcomes:
fun synchronizeAccount(): Outcome<SyncResult, SyncError> {
// First operation
val messagesOutcome = fetchMessages()
if (messagesOutcome is Outcome.Failure) {
return Outcome.Failure(SyncError.MessageSyncFailed(messagesOutcome.error))
}
// Second operation using the result of the first
val messages = messagesOutcome.getOrNull()!!
val folderOutcome = updateFolders(messages)
if (folderOutcome is Outcome.Failure) {
return Outcome.Failure(SyncError.FolderUpdateFailed(folderOutcome.error))
}
// Return success with combined results
return Outcome.Success(
SyncResult(
messageCount = messages.size,
folderCount = folderOutcome.getOrNull()!!.size
)
)
}
The application uses a structured logging system with a well-defined API:
core/logging/api) defines interfaces like Logger and LogSinkDefaultLogger implements the Logger interface and delegates to a LogSinkVERBOSE: Most detailed log level for debuggingDEBUG: Detailed information for diagnosing problemsINFO: General information about application flowWARN: Potential issues that don't affect functionalityERROR: Issues that affect functionality but don't crash the applicationWhen adding logging to your code:
Inject a Logger into your class:
class AccountRepository(
private val apiClient: ApiClient,
private val logger: Logger,
) {
// Repository implementation
}
Choose the appropriate log level based on the importance of the information:
verbose for detailed debugging information (only visible in debug builds)debug for general debugging informationinfo for important events that should be visible in productionwarn for potential issues that don't affect functionalityerror for issues that affect functionalityUse lambda syntax to avoid string concatenation when logging isn't needed:
// Good - string is only created if this log level is enabled
logger.debug { "Processing message with ID: $messageId" }
// Avoid - string is always created even if debug logging is disabled
logger.debug("Processing message with ID: " + messageId)
Include relevant context in log messages:
logger.info { "Syncing account: ${account.email}, folders: ${folders.size}" }
Log exceptions with the appropriate level and context:
try {
apiClient.fetchMessages()
} catch (e: Exception) {
logger.error(e) { "Failed to fetch messages for account: ${account.email}" }
throw MessageSyncError.FetchFailed(e)
}
Use tags for better filtering when needed:
private val logTag = LogTag("AccountSync")
fun syncAccount() {
logger.info(logTag) { "Starting account sync for: ${account.email}" }
}
Security is a critical aspect of an email client. The application implements:
legacy/crypto-openpgp module)EncryptionDetector and OpenPgpEncryptionExtractor handle encrypted emails[!NOTE] This section is a work in progress. The security architecture is being developed and will be documented in detail as it evolves.
When implementing security features in your code:
Never store sensitive data in plain text:
// Bad - storing password in plain text
sharedPreferences.putString("password", password)
// Good - use the secure credential storage
val credentialStorage = get<CredentialStorage>()
credentialStorage.storeCredentials(accountUuid, credentials)
Use encryption for sensitive data:
// For data that needs to be stored encrypted
val encryptionManager = get<EncryptionManager>()
val encryptedData = encryptionManager.encrypt(sensitiveData)
database.storeEncryptedData(encryptedData)
Validate user input to prevent injection attacks:
// Validate input before using it
if (!InputValidator.isValidEmailAddress(userInput)) {
throw ValidationError("Invalid email address")
}
Use secure network connections:
// The networking modules enforce TLS by default
// Make sure to use the provided clients rather than creating your own
val networkClient = get<NetworkClient>()
The architecture supports comprehensive testing:
See the Testing guide document for more details on how to write and run tests for the application.
The application includes legacy code that is gradually being migrated to the new architecture:
For more details on the legacy integration, see the Legacy Integration document.
The User Flows provides visual representations of typical user flows through the application, helping to understand how different components interact.