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

πŸ“¦ 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 modules
  • core:<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 implementations
  • feature:<feature-name>:impl-<variant> for variant-specific implementations
  • core:<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 implementation
  • feature:account:impl-yahoo - Yahoo-specific implementation
  • feature: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 utilities
  • core:<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 implementations
  • core:<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 code
  • core:<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:

  1. One-Way Dependencies:
    • Modules should not depend on each other in a circular manner
    • Dependencies should form a directed acyclic graph (DAG)
  2. API-Implementation Separation:
    • Modules should depend only on API modules, not implementation modules
    • Implementation modules should be referenced only in dependency injection setup
  3. 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
  4. 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
  5. 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:

  1. Distinct Functionality: The code represents a distinct piece of functionality with clear boundaries
  2. Reusability: The functionality could be reused across multiple features or applications
  3. Build Performance: Breaking down large modules improves build performance
  4. Testing: Isolation improves testability

When to Split a Module

Split an existing module when:

  1. Size: The module has grown too large (>10,000 lines of code as a rough guideline)
  2. Complexity: The module has become too complex with many responsibilities
  3. Dependencies: The module has too many dependencies
  4. Build Time: The module takes too long to build

When to Keep Modules Together

Keep functionality in the same module when:

  1. Cohesion: The functionality is highly cohesive and tightly coupled
  2. Small Size: The functionality is small and simple
  3. Single Responsibility: The functionality represents a single responsibility
Last change: 2025-06-26, commit: 3b4ef25