Keyboard shortcuts

Press โ† or โ†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

๐Ÿ—๏ธ Architecture

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.

๐Ÿ”‘ Key Architectural Principles

  • ๐Ÿš€ Multi platform Compatibility: The architecture is designed to support future Kotlin Multiplatform adoption
  • ๐Ÿ“ฑ Offline-First: The application is designed to work offline with local data storage and synchronization with remote servers
  • ๐Ÿงฉ Modularity: The application is divided into distinct modules with clear responsibilities
  • ๐Ÿ”€ Separation of Concerns: Each module focuses on a specific aspect of the application
  • โฌ‡๏ธ Dependency Inversion: Higher-level modules do not depend on lower-level modules directly
  • ๐ŸŽฏ Single Responsibility: Each component has a single responsibility
  • ๐Ÿ”„ API/Implementation Separation: Clear separation between public APIs and implementation details
  • ๐Ÿงน Clean Architecture: Separation of UI, domain, and data layers
  • ๐Ÿงช Testability: The architecture facilitates comprehensive testing at all levels

๐Ÿ“ Architecture Decision Records

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.

๐Ÿ“ฆ Module Structure

The application is organized into several module types:

  • ๐Ÿ“ฑ App Modules: app-thunderbird and app-k9mail - Application entry points
  • ๐Ÿ”„ App Common: app-common - Shared code between applications
  • โœจ Feature Modules: feature:* - Independent feature modules
  • ๐Ÿงฐ Core Modules: core:* - Foundational components and utilities used across multiple features
  • ๐Ÿ“š Library Modules: library:* - Specific implementations for reuse
  • ๐Ÿ”™ Legacy Modules: Legacy code being gradually migrated

For more details on the module organization and structure, see the Module Organization and Module Structure documents.

๐Ÿงฉ Architectural Patterns

The architecture follows several key patterns to ensure maintainability, testability, and separation of concerns:

๐Ÿ”„ API/Implementation Separation

Each module should be split into two main parts: API and implementation. This separation provides clear boundaries between what a module exposes to other modules and how it implements its functionality internally:

  • ๐Ÿ“ API: Public interfaces, models, and contracts
  • โš™๏ธ Implementation: Concrete implementations of the interfaces

This separation provides clear boundaries, improves testability, and enables flexibility.

See API Module and Implementation Module for more details.

Clean Architecture

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

๐Ÿ–ผ๏ธ UI Layer (Presentation)

The UI layer is responsible for displaying data to the user and handling user interactions.

Key Components:

  • ๐ŸŽจ Compose UI: Screen components built with Jetpack Compose
  • ๐Ÿง  ViewModels: Manage UI state and handle UI events
  • ๐Ÿ“Š UI State: Immutable data classes representing the UI state
  • ๐ŸŽฎ Events: User interactions or system events that trigger state changes
  • ๐Ÿ”” Effects: One-time side effects like navigation or showing messages

Pattern: Model-View-Intent (MVI)

  • ๐Ÿ“‹ Model: UI state representing the current state of the screen
  • ๐Ÿ‘๏ธ View: Compose UI that renders the state
  • ๐ŸŽฎ Event: User interactions that trigger state changes (equivalent to โ€œIntentโ€ in standard MVI)
  • ๐Ÿ”” Effect: One-time side effects like navigation or notifications

๐Ÿง  Domain Layer (Business Logic)

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:

  • โš™๏ธ Use Cases: Encapsulate business logic operations
  • ๐Ÿ“‹ Domain Models: Represent business entities
  • ๐Ÿ“ Repository Interfaces: Define data access contracts
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

๐Ÿ’พ Data Layer

The data layer is responsible for data retrieval, storage, and synchronization.

Key Components:

  • ๐Ÿ“ฆ Repository implementations: Implement repository interfaces from the domain layer
  • ๐Ÿ”Œ Data Sources: Provide data from specific sources (API, database, preferences)
  • ๐Ÿ“„ Data Transfer Objects: Represent data at the data layer

Pattern: Data Source Pattern

  • ๐Ÿ” Abstracts data sources behind a clean API
  • Maps data between domain models and data transfer objects
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

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:

  • Predictability: With immutable state, the UI can only change when a new state object is provided, making the flow of data more predictable and easier to reason about.
  • Debugging: Each state change creates a new state object, making it easier to track changes and debug issues by comparing state objects.
  • Concurrency: Immutable objects are thread-safe by nature, eliminating many concurrency issues.
  • Performance: While creating new objects might seem inefficient, modern frameworks optimize this process, and the benefits of immutability often outweigh the costs.
  • Time-travel debugging: Immutability enables storing previous states, allowing developers to โ€œtime travelโ€ back to previous application states during debugging.

๐ŸŽจ UI Architecture

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.

๐Ÿ“ฑ Offline-First Approach

The application implements an offline-first Approach to provide a reliable user experience regardless of network conditions:

  • ๐Ÿ’พ Local database as the single source of truth
  • ๐Ÿ”„ Background synchronization with remote servers
  • ๐Ÿ“‹ Operation queueing for network operations
  • ๐Ÿ”€ Conflict resolution for data modified both locally and remotely

Implementation Approach

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:

  1. ๐Ÿ’พ Data Layer:
    • ๐Ÿ“Š Local database as the primary data source
    • ๐ŸŒ Remote data source for server communication
    • ๐Ÿ“ฆ Repository pattern to coordinate between data sources
    • ๐Ÿ”„ Synchronization manager to handle data syncing
  2. ๐Ÿง  Domain Layer:
    • โš™๏ธ Use cases handle both online and offline scenarios
    • ๐Ÿ“ Business logic accounts for potential network unavailability
    • ๐Ÿ“‹ Domain models represent data regardless of connectivity state
  3. ๐Ÿ–ผ๏ธ UI Layer:
    • ๐Ÿง  ViewModels expose UI state that reflects connectivity status
    • ๐Ÿšฆ UI components display appropriate indicators for offline mode
    • ๐Ÿ‘† User interactions are designed to work regardless of connectivity

๐Ÿ’‰ Dependency Injection

The application uses Koin for dependency injection, with modules organized by feature:

  • ๐Ÿ“ฑ App Modules: Configure application-wide dependencies
  • ๐Ÿ”„ App Common: Shared dependencies between applications
  • โœจ Feature Modules: Configure feature-specific dependencies
  • ๐Ÿงฐ Core Modules: Configure core dependencies
// 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

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:

  • โš ๏ธ Error Handling: Comprehensive error handling (core/outcome) transforms exceptions into domain-specific errors and provides user-friendly feedback.
  • ๐Ÿ“‹ Logging: Centralized logging system (core/logging) ensures consistent log formatting, levels, and storage.
  • ๐Ÿ”’ Security: Modules like core/security handle encryption, authentication, and secure data storage.

Work in progress:

  • ๐Ÿ” Encryption: The core/crypto module provides encryption and decryption utilities for secure data handling.
  • ๐Ÿ“ฆ Feature Flags: The core/feature-flags module manages feature toggles and experimental features.
  • ๐Ÿ”„ Synchronization: The core/sync module manages background synchronization, conflict resolution, and offline-first behavior.
  • ๐Ÿ› ๏ธ Configuration Management: Centralized handling of application settings and environment-specific configurations.

By implementing these concerns as core modules, the application achieves a clean and modular architecture that is easier to maintain and extend.

โš ๏ธ Error Handling

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.

  • ๐Ÿง  Domain Errors: Encapsulate business logic errors as sealed classes, ensuring clear representation of specific error cases.
  • ๐Ÿ’พ Data Errors: Transform network or database exceptions into domain-specific errors using result patterns in repository implementations.
  • ๐Ÿ–ผ๏ธ UI Error Handling: Provide user-friendly error feedback by:
    • Mapping domain errors to UI state in ViewModels.
    • Displaying actionable error states in Compose UI components.
    • Offering retry options for network connectivity issues.

note

Exceptions should be used sparingly. Favor the Outcome pattern and sealed classes for predictable error conditions to enhance maintainability and clarity.

๐Ÿ› ๏ธ How to Implement Error Handling

When implementing error handling in your code:

  1. 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()
    }
    
  2. 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>()
    }
    
  3. 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))
        }
    }
    
  4. 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)
        }
    }
    
  5. 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) }
            }
        }
    }
    
  6. 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))
        }
    }
    
  7. 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
            )
        )
    }
    

๐Ÿ“ Logging

The application uses a structured logging system with a well-defined API:

  • ๐Ÿ“Š Logging Architecture:
    • Core logging API (core/logging/api) defines interfaces like Logger and LogSink
    • Multiple implementations (composite, console) allow for flexible logging targets
    • Composite implementation enables logging to multiple sinks simultaneously
  • ๐Ÿ”„ Logger vs. Sink:
    • Logger: The front-facing interface that application code interacts with to create log entries
      • Provides methods for different log levels (verbose, debug, info, warn, error)
      • Handles the creation of log events with appropriate metadata (timestamp, tag, etc.)
      • Example: DefaultLogger implements the Logger interface and delegates to a LogSink
    • LogSink: The back-end component that receives log events and determines how to process them
      • Defines where and how log messages are actually stored or displayed
      • Filters log events based on configured log levels
      • Can be implemented in various ways (console output, file storage, remote logging service)
      • Multiple sinks can be used simultaneously via composite pattern
  • ๐Ÿ“‹ Log Levels:
    • VERBOSE: Most detailed log level for debugging
    • DEBUG: Detailed information for diagnosing problems
    • INFO: General information about application flow
    • WARN: Potential issues that donโ€™t affect functionality
    • ERROR: Issues that affect functionality but donโ€™t crash the application

๐Ÿ› ๏ธ How to Implement Logging

When adding logging to your code:

  1. Inject a Logger into your class:

    class AccountRepository(
        private val apiClient: ApiClient,
        private val logger: Logger,
    ) {
        // Repository implementation
    }
    
  2. Choose the appropriate log level based on the importance of the information:

    • Use verbose for detailed debugging information (only visible in debug builds)
    • Use debug for general debugging information
    • Use info for important events that should be visible in production
    • Use warn for potential issues that donโ€™t affect functionality
    • Use error for issues that affect functionality
  3. Use 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)
    
  4. Include relevant context in log messages:

    logger.info { "Syncing account: ${account.email}, folders: ${folders.size}" }
    
  5. 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)
    }
    
  6. Use tags for better filtering when needed:

    private val logTag = LogTag("AccountSync")
    
    fun syncAccount() {
        logger.info(logTag) { "Starting account sync for: ${account.email}" }
    }
    

๐Ÿ”’ Security

Security is a critical aspect of an email client. The application implements:

  • ๐Ÿ” Data Encryption:
    • End-to-end encryption using OpenPGP (via the legacy/crypto-openpgp module)
    • Classes like EncryptionDetector and OpenPgpEncryptionExtractor handle encrypted emails
    • Local storage encryption for sensitive data like account credentials
  • ๐Ÿ”‘ Authentication:
    • Support for various authentication types (OAuth, password, client certificate)
    • Secure token storage and management
    • Authentication error handling and recovery
  • ๐Ÿ›ก๏ธ Network Security:
    • TLS for all network connections with certificate validation
    • Certificate pinning for critical connections
    • Protection against MITM attacks

note

This section is a work in progress. The security architecture is being developed and will be documented in detail as it evolves.

๐Ÿ› ๏ธ How to Implement Security

When implementing security features in your code:

  1. 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)
    
  2. 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)
    
  3. Validate user input to prevent injection attacks:

    // Validate input before using it
    if (!InputValidator.isValidEmailAddress(userInput)) {
        throw ValidationError("Invalid email address")
    }
    
  4. 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>()
    

๐Ÿงช Testing Strategy

The architecture supports comprehensive testing:

  • ๐Ÿ”ฌ Unit Tests: Test individual components in isolation
  • ๐Ÿ”Œ Integration Tests: Test interactions between components
  • ๐Ÿ“ฑ UI Tests: Test the UI behavior and user flows

See the Testing guide document for more details on how to write and run tests for the application.

๐Ÿ”™ Legacy Integration

The application includes legacy code that is gradually being migrated to the new architecture:

  • ๐Ÿ“ฆ Legacy Modules: Contain code from the original K-9 Mail application
  • ๐Ÿ”„ Migration Strategy: Gradual migration to the new architecture
  • ๐Ÿ”Œ Integration Points: Clear interfaces between legacy and new code

For more details on the legacy integration, see the Legacy Integration document.

๐Ÿ”„ User Flows

The User Flows provides visual representations of typical user flows through the application, helping to understand how different components interact.