π¦ Module Structure
The Thunderbird for Android project is following a modularization approach, where the codebase is divided into multiple distinct modules. These modules encapsulate specific functionality and can be developed, tested, and maintained independently. This modular architecture promotes reusability, scalability, and maintainability of the codebase.
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.
When a feature is complex, it can be further split into sub modules, allowing for better organization and smaller modules for distinct functionalities within a feature domain.
This approach promotes:
- Loose coupling: Modules interact through well-defined interfaces
- Interchangeable implementations: Different implementations can be swapped without affecting consumers
- Improved build times: Reduces the scope of recompilation when changes are made
- Better testability: Modules can be tested in isolation
- Clear ownership: Teams can own specific modules
π API Module
The API module defines the public contract that other modules can depend on. It should be stable, well-documented, and change infrequently.
The API module contains:
- Public interfaces: Contracts that define the moduleβs capabilities
- Data models: Entities that are part of the public API
- Constants and enums: Shared constants and enumeration types
- Extension functions: Utility functions that extend public types
- Navigation definitions: Navigation routes and arguments
The API module should be minimal and focused on defining the contract that other modules can depend on. It should not contain any implementation details.
Naming Convention
API modules should follow the naming convention:
feature:<feature-name>:api
for feature modulescore:<core-name>:api
for core modules
Example structure for a feature API module:
feature:account:api
βββ src/main/kotlin/net/thunderbird/feature/account/api
β βββ AccountManager.kt (interface)
β βββ Account.kt (entity)
β βββ AccountNavigation.kt (interface)
β βββ AccountType.kt (entity)
β βββ AccountExtensions.kt (extension functions)
API Design Guidelines
When designing APIs, follow these principles:
- Minimal surface area: Expose only what is necessary
- Immutable data: Use immutable data structures where possible
- Clear contracts: Define clear method signatures with documented parameters and return values
- Error handling: Define how errors are communicated (exceptions, result types, etc.)
βοΈ Implementation Module
The implementation module depends on the API module but should not be depended upon by other modules (except for dependency injection setup).
The implementation module contains:
- Interface implementations: Concrete implementations of the interfaces defined in the API module
- Internal components: Classes and functions used internally
- Data sources: Repositories, database access, network clients
- UI components: Screens, composables, and ViewModels
Naming Convention
Implementation modules should follow the naming convention:
feature:<feature-name>:impl
for standard implementationsfeature:<feature-name>:impl-<variant>
for variant-specific implementationscore:<core-name>:impl
for core module implementations
Multiple Implementations
When multiple implementations are needed, such as for different providers or platforms, they can be placed in separate modules and named accordingly:
feature:account:impl-gmail
- Gmail-specific implementationfeature:account:impl-yahoo
- Yahoo-specific implementationfeature:account:impl-noop
- No-operation implementation for testing
Example structure for a variant implementation:
feature:account:impl-gmail
βββ src/main/kotlin/app/thunderbird/feature/account/gmail
β βββ GmailAccountManager.kt
Clean Architecture in Implementation Modules
A complex feature implementation module should apply Clean Architecture principles, separating concerns into:
- UI Layer: Compose UI components, ViewModels, and UI state management
- Domain Layer: Use cases, domain models, and business logic
- Data Layer: Repositories, data sources, and data mapping
feature:account:impl
βββ src/main/kotlin/app/thunderbird/feature/account/impl
β βββ data/
β β βββ repository/
β β βββ datasource/
β β βββ mapper/
β βββ domain/
β β βββ repository/
β β βββ entity/
β β βββ usecase/
β βββ ui/
β βββ AccountScreen.kt
β βββ AccountViewModel.kt
Implementation Best Practices
- Internal visibility: Use the
internal
modifier for classes and functions that should not be part of the public API - Encapsulation: Keep implementation details hidden from consumers
- Testability: Design implementations to be easily testable
- Dependency injection: Use constructor injection for dependencies
- Error handling: Implement robust error handling according to API contracts
- Performance: Consider performance implications of implementations
- Logging: Include appropriate logging for debugging and monitoring
π§ͺ Testing Module
Testing modules provide test implementations, utilities, and frameworks for testing other modules. They are essential for ensuring the quality and correctness of the codebase.
Contents
The testing module contains:
- Test utilities: Helper functions and classes for testing
- Test frameworks: Custom test frameworks and extensions
- Test fixtures: Reusable test setups and teardowns
- Test matchers: Custom matchers for assertions
Naming Convention
Testing modules should follow the naming convention:
feature:<feature-name>:testing
for feature-specific test utilitiescore:<core-name>:testing
for core test utilities<module-name>:test
for module-specific tests
Example structure for a testing module:
feature:account:testing
βββ src/main/kotlin/app/thunderbird/feature/account/testing
β βββ AccountTestUtils.kt
β βββ AccountTestMatchers.kt
Testing Best Practices
- Reusability: Create reusable test utilities and data factories
- Isolation: Tests should be isolated and not depend on external systems
- Readability: Tests should be easy to read and understand
- Maintainability: Tests should be easy to maintain and update
- Coverage: Tests should cover all critical paths and edge cases
π€ Fake Module
Fake modules provide alternative implementations of interfaces for testing, development, or demonstration purposes. They are simpler than the real implementations and are designed to be used in controlled environments.
Contents
The fake module contains:
- Fake implementations: Simplified implementations of interfaces
- Generic test data: Basic, reusable sample data for testing and demonstration
- In-memory data stores: In-memory alternatives to real data stores
- Controlled behavior: Implementations with predictable, configurable behavior
- Test doubles: Mocks, stubs, and spies for testing
important
Fake modules should be limited to the most generic data and implementations. Specific use cases or test setups should be part of the actual test, not the fake module.
Naming Convention
Fake modules should follow the naming convention:
feature:<feature-name>:fake
for feature-specific fake implementationscore:<core-name>:fake
for core fake implementations
Example structure for a fake module:
feature:account:fake
βββ src/main/kotlin/app/thunderbird/feature/account/fake
β βββ FakeAccountRepository.kt
β βββ FakeAccountDataSource.kt
β βββ InMemoryAccountStore.kt
β βββ FakeAccountManager.kt
β βββ data/
β βββ FakeAccountData.kt
β βββ FakeAccountProfileData.kt
Fake Implementation Best Practices
- Simplicity: Fake implementations should be simpler than real implementations
- Deterministic behavior: Behavior should be predictable and controllable
- Configuration: Allow configuration of behavior for different test scenarios
- Visibility: Make internal state visible for testing assertions
- Performance: Fake implementations should be fast for testing efficiency
- Generic test data: Include basic, reusable test data that can be used across different tests
- Realistic but generic data: Test data should be realistic enough to be useful but generic enough to be reused
- Separation of concerns: Keep specific test scenarios and edge cases in the actual tests, not in the fake module
π Common Module
Common modules provide shared functionality that is used by multiple modules within a feature. They contain implementation details, utilities, and components that need to be shared between related modules but are not part of the public API.
Contents
The common module contains:
- Shared utilities: Helper functions and classes used across related modules
- Internal implementations: Implementation details shared between modules
- Shared UI components: Reusable UI components specific to a feature domain
- Data repositories: Shared data storage and access implementations
- Constants and resources: Shared constants, strings, and other resources
Naming Convention
Common modules should follow the naming convention:
feature:<feature-name>:common
for feature-specific common codecore:<core-name>:common
for core common code
Example structure for a common module:
feature:account:common
βββ src/main/kotlin/net/thunderbird/feature/account/common
β βββ AccountCommonModule.kt
β βββ data/
β β βββ InMemoryAccountStateRepository.kt
β βββ domain/
β β βββ AccountDomainContract.kt
β β βββ input/
β β β βββ NumberInputField.kt
β β βββ entity/
β β βββ AccountState.kt
β β βββ AccountDisplayOptions.kt
β β βββ AuthorizationState.kt
β βββ ui/
β βββ WizardNavigationBar.kt
β βββ WizardNavigationBarState.kt
Common Module Best Practices
- Internal visibility: Use the
internal
modifier for classes and functions that should not be part of the public API - Clear organization: Organize code into data, domain, and UI packages for better maintainability
- Shared contracts: Define clear interfaces for functionality that will be implemented by multiple modules
- Reusable components: Create UI components that can be reused across different screens within a feature
- Stateless where possible: Design components to be stateless and receive state through parameters
- Minimal dependencies: Keep dependencies to a minimum to avoid transitive dependency issues
- Documentation: Document the purpose and usage of shared components
- Avoid leaking implementation details: Donβt expose implementation details that could create tight coupling
π Module Dependencies
The module dependency diagram below illustrates how different modules interact with each other in the project, showing the dependencies and integration points between modules.
graph TB subgraph APP[App Modules] direction TB APP_TB["`**:app-thunderbird**<br>Thunderbird for Android`"] APP_K9["`**:app-k9mail**<br>K-9 Mail`"] end subgraph COMMON[App Common Module] direction TB COMMON_APP["`**:app-common**<br>Integration Code`"] end subgraph FEATURE[Feature] direction TB FEATURE1[feature:account:api] FEATURE2[feature:account:impl] FEATURE3[Feature 2] FEATURE_K9[Feature K-9 Only] FEATURE_TB[Feature TfA Only] end subgraph CORE[Core] direction TB CORE1[Core 1] CORE2[Core 2] end subgraph LIBRARY[Library] direction TB LIB1[Library 1] LIB2[Library 2] end APP_K9 --> |depends on| COMMON_APP APP_TB --> |depends on| COMMON_APP COMMON_APP --> |integrates| FEATURE1 COMMON_APP --> |injects| FEATURE2 FEATURE2 --> FEATURE1 COMMON_APP --> |integrates| FEATURE3 APP_K9 --> |integrates| FEATURE_K9 APP_TB --> |integrates| FEATURE_TB FEATURE1 --> |uses| CORE1 FEATURE3 --> |uses| CORE2 FEATURE_TB --> |uses| CORE1 FEATURE_K9 --> |uses| LIB2 CORE2 --> |uses| LIB1 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 FEATURE_K9 featureK9 class FEATURE_TB featureTB class CORE core class CORE1,CORE2 core_module class LIBRARY library class LIB1,LIB2 library_module
Module Interaction Patterns
- App Modules: Depend on the App Common module for shared functionality and selectively integrate feature modules
- App Common: Integrates various feature modules to provide a cohesive application
- Feature Modules: Use core modules and libraries for their implementation, may depend on other feature API modules
- App-Specific Features: Some features are integrated directly by specific apps (K-9 Mail or Thunderbird)
Dependency Rules
These rules must be strictly followed:
- One-Way Dependencies:
- Modules should not depend on each other in a circular manner
- Dependencies should form a directed acyclic graph (DAG)
- API-Implementation Separation:
- Modules should depend only on API modules, not implementation modules
- Implementation modules should be referenced only in dependency injection setup
- Feature Integration:
- Features should be integrated through the App Common module, which acts as a central hub
- Direct dependencies between feature implementations should be avoided, or limited to API modules
- Dependency Direction:
- Dependencies should flow from app modules to common, then to features, and finally to core and libraries
- Higher-level modules should depend on lower-level modules, not vice versa
- Minimal Dependencies:
- Each module should have the minimal set of dependencies required
- Avoid unnecessary dependencies that could lead to bloat
Dependency Management
- Explicit Dependencies: All dependencies should be explicitly declared in the moduleβs build file
- Transitive Dependencies: Avoid relying on transitive dependencies
- Version Management: Use centralized version management for dependencies
- Dependency Visibility: Use appropriate visibility modifiers to limit access to implementation details
Dependency Injection
- Use Koin for dependency injection
- Configure module dependencies in dedicated Koin modules
- Inject API interfaces, not implementation classes
- Use lazy injection where appropriate to improve startup performance
π Module Granularity
Determining the right granularity for modules is crucial for maintainability and scalability. This section provides guidelines on when to create new modules and how to structure them.
When to Create a New Module
Create a new module when:
- Distinct Functionality: The code represents a distinct piece of functionality with clear boundaries
- Reusability: The functionality could be reused across multiple features or applications
- Build Performance: Breaking down large modules improves build performance
- Testing: Isolation improves testability
When to Split a Module
Split an existing module when:
- Size: The module has grown too large (>10,000 lines of code as a rough guideline)
- Complexity: The module has become too complex with many responsibilities
- Dependencies: The module has too many dependencies
- Build Time: The module takes too long to build
When to Keep Modules Together
Keep functionality in the same module when:
- Cohesion: The functionality is highly cohesive and tightly coupled
- Small Size: The functionality is small and simple
- Single Responsibility: The functionality represents a single responsibility