- proposed
Thunderbird for Android uses a modular architecture. Many feature and core areas are split into api (public contracts)
and impl (implementation) modules, e.g. :feature:account:settings:api and :feature:account:settings:impl.
Over time, the main friction was ambiguity in naming and ownership:
- Inconsistent use of
apiin package names made it unclear whether a type was part of a public contract. - Lack of a clear convention for naming feature-internal packages (
impl,internal, or none) led to confusion. - Team wasn’t aligned on what belongs in
api(stable contracts) vs. what should stay internal to the feature.
To improve clarity and discoverability, we rename impl modules to internal and formalize module, package, and
content rules for both API and internal code — for both feature and core modules.
:feature:*:apimodules define the public contracts exposed to other features.:feature:*:implmodules are renamed to:feature:*:internal, marking them as private implementation details.:core:*:apimodules define public contracts exposed to feature and other core modules.:core:*:implmodules are renamed to:core:*:internal, marking them as private implementation details.- Other modules must only declare dependencies on
:feature:*:apior:core:*:apiof other areas. Depending on:feature:*:internalor:core:*:internalfrom a different area is prohibited. - Binding of contracts to implementations happens in central composition modules (application assembly):
:app-commonand the app-specific modules:app-k9mailand:app-thunderbird.
Put only stable, intentionally shared contracts in api:
- Public interfaces and abstractions other features depend on (e.g., repositories, use cases, service interfaces).
- Data contracts exchanged across features (DTOs/value objects).
- Navigation contracts and events that other features can trigger/observe.
- DI entry points/interfaces needed by composition modules (
:app-common, app modules).
Keep everything else in internal:
- Implementations of the above contracts (repositories, data sources, mappers, use case implementations).
- UI implementations, view models, and UI state models scoped to the feature.
- Feature-scoped DI wiring, modules, and factories.
- Experimental or volatile details that must not be exposed as public API.
Notes for core modules:
- The same rules apply. Core
apiexposes stable, shared infrastructure contracts (e.g., logging, networking abstractions, serialization, clock, crypto interfaces). Coreinternalcontains their implementations and wiring.
- Standard two-module shape for a feature area:
:feature:<area>[:<subarea>]:api:feature:<area>[:<subarea>]:internal(formerlyimpl)
- Standard two-module shape for a core area:
:core:<area>[:<subarea>]:api:core:<area>[:<subarea>]:internal(formerlyimpl)
- If there are multiple implementation variants, suffix the
internalmodule with a qualifier, e.g. rename:feature:mail:message:export:impl-emlto:feature:mail:message:export:internal-eml. - Library/legacy modules may adopt this pattern later but are currently out of scope for this ADR.
- For features, in
:feature:*:api, usenet.thunderbird.feature.<area>[.<subarea>], e.g.:net.thunderbird.feature.account.settings,net.thunderbird.feature.mail.message.reader. - For features, in
:feature:*:internal, mirror the API package structure but place all implementation under an.internalsegment, e.g.:net.thunderbird.feature.account.settings.internal,net.thunderbird.feature.mail.message.reader.internal.data,net.thunderbird.feature.mail.message.reader.internal.domain. - For core, in
:core:*:api, usenet.thunderbird.core.<area>[.<subarea>], e.g.:net.thunderbird.core.network. - For core, in
:core:*:internal, mirror the API package structure and place implementation under.internal, e.g.:net.thunderbird.core.network.internal,net.thunderbird.core.crypto.internal. - Multiple variants (several implementations of the same contract): reflect the module qualifier after the
.internalsegment.- Mapping:
:…:internal-<variant>→ package…internal.<variant>. - Feature examples:
- Module
:feature:mail:message:export:internal-eml→ package rootnet.thunderbird.feature.mail.message.export.internal.eml(e.g.,…internal.eml.data,…internal.eml.domain). - If a PDF exporter exists:
:feature:mail:message:export:internal-pdf→net.thunderbird.feature.mail.message.export.internal.pdf.
- Module
- Core examples:
- Module
:core:network:internal-okhttp→net.thunderbird.core.network.internal.okhttp. - Module
:core:crypto:internal-bouncycastle→net.thunderbird.core.crypto.internal.bouncycastle.
- Module
- Keep variant tokens lowercase and alphanumeric. Use dot separators for dimension/value patterns (see below). Avoid kebab-case in package names.
- Mapping:
- Multi-dimension variants: when a variant expresses a dimension and a value, prefer
internal.<dimension>.<value>.- Examples:
net.thunderbird.core.storage.internal.database.sqlite,net.thunderbird.feature.search.internal.engine.lucene. - Prefer at most two levels under
.internalfor the variant part to keep packages readable.
- Examples:
Note
- Avoid adding
.apito package names for new code — the module already is the API. - Prefer small focused API packages with narrow sets of contracts; keep type stability in mind when promoting code to API.
- API packages should remain variant-agnostic in almost all cases; concrete variants live under
.internal.<variant>.
- Feature-to-feature dependencies must target
:feature:*:apionly. - Feature-to-core dependencies must target
:core:*:apionly. - Core-to-core dependencies must target
:core:*:apionly. - Core modules must not depend on
:feature:*modules. :feature:*:internaland:core:*:internaldependencies are only allowed from:- the same area’s
apimodule (when strictly necessary), and - composition modules:
:app-common,:app-k9mail,:app-thunderbird.
- the same area’s
- Build logic will add a check that fails the build if a module depends on a
:*:internaloutside of these exceptions.
- Rename modules in Gradle from
impltointernal:- Examples in
settings.gradle.ktsto update::feature:account:avatar:impl→:feature:account:avatar:internal:feature:account:settings:impl→:feature:account:settings:internal:feature:mail:message:export:impl-eml→:feature:mail:message:export:internal-eml:core:<area>:impl→:core:<area>:internal(and for subareas accordingly)
- Examples in
- Adjust
namespaceinbuild.gradle.ktsand Kotlin/Javapackagedeclarations to include.internal. - Update imports and references after package moves.
- Add build-plugin check to disallow external dependencies on
:feature:*:internaland:core:*:internal(enforced in the root build). - Move all composition wiring (DI, factory bindings, navigation registrations) to
:app-commonor app modules. - When in doubt, prefer starting in
internal. Promote types toapionly once they’re needed and stable.
- Explicit and discoverable boundary between public and internal code.
- Stronger enforcement of architectural intent; easier refactors.
- Package-level naming makes internal code clearly visible as non-public.
- Works cleanly with KMP source sets (common/platform-specific).
- Reduced confusion around whether a type is public or internal.
- Clear criteria for placing types in
apivs.internalimproves consistency.
- Requires renaming existing modules and packages, plus updating imports.
- Slight learning overhead for contributors until the pattern becomes familiar.
- Temporary churn in open branches during the migration window.