Shared Internals: Kotlin's New Proposal for Cross-Module Visibility

skydovesJaewoong Eum (skydoves)||15 min read

Shared Internals: Kotlin's New Proposal for Cross-Module Visibility

Kotlin's internal visibility modifier provides a useful mechanism for hiding implementation details within a module while exposing a clean public API. But as codebases grow and libraries modularize, a tension emerges: the logical boundaries of your API don't always align with the compilation boundaries of your modules. Test modules need access to production internals. Library families like kotlinx.coroutines want to share implementation details across artifacts without exposing them to consumers. The current workaround, "friend modules," is an undocumented compiler feature that lacks language-level design.

KEEP-0451 proposes a solution: the shared internal visibility modifier. This new visibility level sits between internal and public, allowing modules to explicitly declare which internals they share and with whom. In this article, you'll explore the motivation behind this proposal, the design decisions that shaped it, how transitive sharing simplifies complex dependency graphs, and the technical challenges of implementing cross-module visibility on the JVM.

The fundamental problem: Module boundaries vs. logical boundaries

Consider a typical library structure:

kotlinx-coroutines/
├── kotlinx-coroutines-core/
├── kotlinx-coroutines-test/
├── kotlinx-coroutines-reactive/
└── kotlinx-coroutines-android/

These artifacts form a cohesive library family. Internally, they share implementation details: dispatcher internals, continuation machinery, and testing utilities. But from Kotlin's perspective, each artifact is a separate module. The internal modifier in kotlinx-coroutines-core is invisible to kotlinx-coroutines-test, even though both are maintained by the same team and shipped together.

The current workarounds are unsatisfying:

Option 1: Make everything public. This works, but pollutes the API surface. Consumers (developers) see implementation details they shouldn't use, and maintainers lose the ability to change internals without breaking compatibility.

Option 2: Use the undocumented friend modules feature. The Kotlin compiler supports a -Xfriend-paths flag that grants one module access to another's internals. But this is a compiler implementation detail, not a language feature. It has no syntax, no IDE support, and no guarantees of stability.

Option 3: Merge modules. You could combine related modules into a single compilation unit, then split them for distribution. But this complicates build configurations and doesn't scale to complex dependency graphs.

KEEP-0451 addresses this gap by elevating friend modules to a first-class language feature with explicit syntax and clear semantics.

The shared internal modifier

The proposal introduces a new visibility modifier: shared internal. Declarations marked with this modifier are visible to designated dependent modules, but invisible to the general public.

// In kotlinx-coroutines-core
shared internal fun internalDispatcherHelper() {
    // Implementation shared with other coroutines modules
}

shared internal class ContinuationImpl {
    // Shared implementation class
}

The key distinction from internal:

  • internal: Visible only within the same module.
  • shared internal: Visible within the same module AND to explicitly designated dependent modules.
  • public: Visible to everyone.

This creates a middle ground. Library authors can share implementation details across their module family without exposing those details to consumers.

Four levels of sharing

The proposal defines four distinct sharing levels, each enabling progressively more access:

Level 0: No sharing

The default behavior. Dependent modules see only public API. This is how most library dependencies work today.

// In library module
internal fun helper() { ... }  // Invisible to dependents
public fun api() { ... }       // Visible to dependents

Level 1: Stability sharing

This level doesn't expose any declarations, but it enables smart casts to work across module boundaries. Consider:

// In library module
public sealed class Result {
    public class Success(val value: Any) : Result()
    public class Failure(val error: Throwable) : Result()
}

// In consumer module
fun process(result: Result) {
    when (result) {
        is Result.Success -> println(result.value)  // Smart cast works
        is Result.Failure -> println(result.error)  // Smart cast works
    }
}

Smart casts require the compiler to prove that a value's type is stable, that it won't change between the type check and the usage. For values defined in other modules, the compiler can't normally make this guarantee because the library might change its implementation. Stability sharing provides this guarantee explicitly.

Level 2: Shared internals

This is the core feature. Dependent modules can access declarations marked shared internal:

// In core module (shares with test module)
shared internal fun createTestDispatcher(): CoroutineDispatcher { ... }

// In test module (receives sharing from core)
fun runTest(block: suspend () -> Unit) {
    val dispatcher = createTestDispatcher()  // Accessible!
    // ...
}

Regular internal declarations remain hidden. Only shared internal declarations become visible.

Level 3: All internals

This is the maximum sharing level, equivalent to the current friend modules behavior. The dependent module sees all internal declarations, not just those marked shared internal. This level exists primarily for test modules that need complete access to production code.

// In production module (all-internals sharing with test module)
internal fun privateHelper() { ... }
shared internal fun sharedHelper() { ... }

// In test module
fun testBehavior() {
    privateHelper()  // Accessible at level 3
    sharedHelper()   // Also accessible
}

Transitive sharing: Simplifying complex hierarchies

One of the proposal's key design decisions is that sharing is transitive. If module A shares with module B, and module B shares with module C, then C automatically sees A's shared internals.

A ──shares──▶ B ──shares──▶ C

C can access A's shared internals

This might seem questionable. Why not require explicit declaration at each step? The proposal explains the rationale through inheritance scenarios.

The diamond problem without transitivity

Consider a class hierarchy spanning modules:

// Module A
shared internal open class Base {
    shared internal fun helper() { ... }
}

// Module B (depends on A)
open class Middle : Base() {
    // Inherits helper()
}

// Module C (depends on B, but not directly on A)
class Derived : Middle() {
    fun doWork() {
        helper()  // Can C call this?
    }
}

Without transitivity, module C can see Middle (it's public) but can't see Base.helper() (it's from A, which C doesn't have sharing with). This creates an impossible situation: C inherits a member it can't access.

With transitivity, the sharing relationship flows through the dependency graph. C's dependency on B, combined with B's sharing from A, grants C access to A's shared internals. The inheritance hierarchy just works.

Computing effective sharing

The effective sharing level between two modules is computed by examining all paths through the dependency graph:

  1. For each path from consumer to provider, take the minimum sharing level along that path
  2. Across all paths, take the maximum of these minimums

This "min across path, max across paths" algorithm ensures that:

  • A single non-sharing hop blocks a path.
  • Multiple paths can provide access even if some paths are blocked.
  • The strongest available path determines the effective level.

The proposal suggests using a modified Dijkstra's algorithm for this computation, treating sharing levels as edge weights and finding the "shortest" (actually highest-sharing) path.

Module identification and declaration

For sharing to work, modules need stable identifiers. The proposal recommends Maven coordinates for published artifacts:

group:artifact:version

For example: org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.0

But module identification gets complicated in several scenarios:

Main vs. test modules

In most build systems, a project's main source and test source compile as separate modules. The test module depends on the main module and typically needs all-internals access. But they share the same Maven coordinates.

The proposal introduces secondary identifiers to distinguish these cases:

org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.0#main
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.0#test

Unpublished modules

Not all modules have Maven coordinates. Internal project modules, local development builds, and multi-module projects need identification without publication. The proposal leaves this to build tool implementations, which might use file paths, project names, or synthetic identifiers.

Version ranges and compatibility

A tricky question: if you share with kotlinx-coroutines-core:1.7.0, does that include version 1.7.1? The proposal doesn't mandate version semantics, leaving flexibility for build tools to implement appropriate policies.

Inheritance rules: All or nothing

The proposal enforces a strict rule for inheritance hierarchies: a class must either see all internal members in its supertype chain or none of them. Partial visibility is forbidden.

Consider why this matters:

// Module A
open class Base {
    internal open fun hook() { defaultBehavior() }
}

// Module B (shares with A)
open class Middle : Base() {
    override fun hook() { customBehavior() }
}

// Module C (does NOT share with A, but shares with B)
class Derived : Middle() {
    // Can Derived override hook()?
}

If C could see Middle.hook() but not Base.hook(), the override chain would be inconsistent. C might override what it thinks is a regular method, not realizing it's part of an internal extension point in A.

The all-or-nothing rule prevents this confusion. If C participates in the hierarchy, it must have visibility into the entire internal structure, or it must treat the class as opaque.

JVM implementation challenges

Implementing cross-module visibility on the JVM presents several technical challenges:

Name mangling

Kotlin's internal visibility is enforced partly through name mangling. Internal declarations get their names modified to include a module identifier, making them inaccessible from other modules:

internal fun helper() { ... }
// Compiles to: helper$moduleName()

For shared internal, the mangling strategy must allow access from designated modules while blocking others. The proposal builds on existing friend modules infrastructure, extending it with proper language-level semantics.

Bridge methods

When a class in module B overrides an internal member from module A, the compiler generates bridge methods to connect the call sites. These bridges must be generated correctly even when the visibility extends across module boundaries.

// Module A
open class Base {
    internal open fun process(): String = "base"
}

// Module B (with sharing from A)
class Derived : Base() {
    override fun process(): String = "derived"
}

The compiler must generate appropriate bridges so that calls to process() from both modules dispatch correctly to the override.

Reflection and tooling

Reflection APIs need to understand shared internal visibility. IDE tooling needs to show shared internals in code completion for authorized modules while hiding them from others. Debug tools need appropriate access. The proposal acknowledges these integration points without mandating specific implementations.

Build tool integration

The proposal includes examples of how build tools might expose sharing configuration:

Gradle DSL (hypothetical)

kotlin {
    sharing {
        // Share with all modules in the same project
        allInternalsTo(project(":app"))

        // Share specific internals with published artifact
        sharedInternalsTo("org.example:other-library")
    }
}

The actual DSL syntax will be determined by build tool maintainers, but the proposal provides guidance on the semantics these tools should support.

Smart cast stability across modules

One subtle benefit of the sharing system is improved smart cast behavior. Kotlin's smart casts require proving that a value is stable, that it won't change between a type check and its use.

For values from other modules, the compiler is conservative. A library might change from a val to a computed property, breaking the stability assumption. With stability sharing (level 1 or higher), the compiler has a guarantee that the declaring module won't make such changes, enabling smart casts that would otherwise fail.

// Library module (provides stability guarantee to consumer)
class Container {
    val item: Any = computeItem()
}

// Consumer module (with stability sharing)
fun process(container: Container) {
    if (container.item is String) {
        // Smart cast to String works because of stability guarantee
        println(container.item.length)
    }
}

Without the sharing relationship, the compiler might reject this smart cast, requiring an explicit cast or local variable.

Design trade-offs and alternatives

The proposal represents several explicit design choices, each reflecting careful consideration of alternatives.

Elevating friend modules to a language feature

The existing friend modules mechanism works at the compiler level through the -Xfriend-paths flag, but it was never designed as a user-facing feature. It lacks source-level syntax for declaring which members should be shared, offers no gradation between "nothing shared" and "everything shared," doesn't define transitivity semantics for inheritance hierarchies, and provides no stability guarantees that would enable smart casts across module boundaries.

Rather than patching these limitations onto an internal compiler feature, the proposal introduces shared internal as a proper language construct. This approach provides clear semantics that developers can reason about, enables IDE support for code completion and error highlighting, and establishes a foundation for future enhancements. The cost is additional language complexity, but the benefit is a coherent visibility model that scales to real-world library architectures.

The case for transitive sharing

An alternative design would require explicit sharing declarations between every pair of modules that need to communicate. Module A would declare sharing with B, and separately with C, and with every other consumer. This explicit approach would give library authors fine-grained control, but it would also require them to anticipate all possible dependency configurations in advance.

The transitive model takes a different stance: sharing relationships flow through the dependency graph automatically. If A shares with B, and B shares with C, then C inherits access to A's shared internals. This design emerged from the inheritance problem discussed earlier. When classes span module boundaries, their internal members must be consistently visible throughout the hierarchy. Transitivity ensures that participating in an inheritance chain grants the necessary visibility without requiring complex declaration matrices.

The trade-off is reduced control. A library author cannot share with B while preventing B's dependents from gaining access. But in practice, this matches how library families actually work: if you trust a module enough to share internals with it, you typically trust its dependents as well.

Graduated visibility levels

A simpler design might offer just two levels: sharing or not sharing. But this binary choice fails to capture the nuances of real use cases. Smart cast stability is a weaker guarantee than full internal access. Test modules need everything, while sibling library modules might only need specific shared utilities.

The four-level graduation addresses these distinctions. Level 0 represents standard dependencies with no special access. Level 1 provides stability guarantees for smart casts without exposing any declarations. Level 2 exposes shared internal members while keeping regular internal members hidden. Level 3 opens everything, matching current test module behavior.

Collapsing these levels would force uncomfortable choices. Without level 1, libraries couldn't provide smart cast stability without also exposing implementation details. Without the distinction between levels 2 and 3, there would be no way to share some internals while protecting others. The granularity adds complexity to the mental model, but it reflects the actual gradations that library authors need.

Explicit opt-in with shared internal

The proposal requires library authors to explicitly mark declarations with shared internal for them to be visible to authorized dependents. An alternative would automatically expose all internal declarations to modules with sharing relationships, treating sharing as a module-level switch rather than a per-declaration choice.

The explicit marking approach preserves encapsulation expectations. When a developer writes internal, they expect that modifier to enforce visibility boundaries. Automatically exposing internals based on module relationships would undermine this expectation. The shared internal modifier represents a conscious decision: this particular declaration is part of the extended API surface shared with trusted dependents, while other internals remain truly internal.

This design also enables incremental adoption. Library authors can introduce sharing relationships without auditing every internal declaration. Only explicitly marked members become visible, limiting the surface area that requires compatibility consideration.

Current status and future directions

KEEP-0451 is currently in progress. The discussion happens in KEEP-469, with related issues tracked in YouTrack (KT-76146 and KT-62688).

Key open questions include:

  • Exact syntax for build tool integration
  • Version compatibility semantics for sharing declarations
  • IDE and tooling support requirements
  • Migration path from existing friend modules usage

The proposal represents a significant enhancement to Kotlin's visibility system, addressing real pain points in library development while maintaining the language's commitment to explicit, understandable semantics.

Conclusion

Kotlin's internal visibility has always represented a trade-off: strong encapsulation within modules at the cost of friction when modules need to share implementation details. KEEP-0451's shared internal modifier addresses this gap with a carefully designed system of explicit sharing declarations, four graduated visibility levels, and transitive propagation through dependency graphs.

The proposal solves real problems faced by library authors: test modules that need production internals, library families that share implementation across artifacts, and smart cast limitations at module boundaries. By elevating friend modules from a compiler hack to a language feature, it provides the syntax, semantics, and tooling integration that production use cases require.

Understanding this proposal helps you anticipate how Kotlin library development might evolve. Whether you're maintaining a multi-module library, designing public APIs, or just curious about language design trade-offs, the thinking behind shared internal reveals how language designers balance encapsulation with practical flexibility. The all-or-nothing inheritance rule, the transitive sharing computation, and the four-level graduation all represent careful decisions about where to draw boundaries in a module system.

As always, happy coding!

— Jaewoong