Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions rfc/710.0000-style-guide-package-private.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
---
type: rfc
rfc: '710.0000'
title: Style guide changes for package private
description: Updates the Flutter style guide and breaking change policy for package-private APIs and @internal.
status: draft
created: 2026-10-06T00:00:00Z
updated: 2026-11-02T18:30:00Z
tags:
- 710-style-guide
- 030-breaking-changes
authors:
- https://github.com/chunhtai
---

# RFC 710.0000: Style guide changes for package private

## Overview

This document proposes updating the [Flutter repository style guide](https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md) and [Flutter breaking change policy](https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes) to discourage rather than ban package-private APIs across `flutter/flutter` and `flutter/packages`, and to adopt `@internal` when package-private APIs are needed.

Declarations in `lib/src/` that are not exported by public barrel files (`lib/<layer>.dart`) and are marked `@internal` fall outside the public API contract; modifying or removing them is not a breaking change.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that is not exported by a public barrel file and is annotated with @internal

This phrasing is problematic.

  • This condition doesn't cover @internal constructors and members of exported classes, which line 102 allows. They're reachable through the barrel, so they fail "not exported", and changing them would still count as a breaking change, which defeats the point of marking them @internal.
  • For top-level declarations, the two halves are equivalent once the analyzer and CI checks are in place: an exported @internal declaration triggers invalid_export_of_internal_element, and the planned checks require unexported declarations to be @internal.
  • It doesn't take the grace period into account.

I recommend rephrasing the condition to be "annotated with @internal" alone, which is likely equivalent to what you're describing but more accurate.


Package-private APIs are permitted only for:

* Web and I/O conditional imports.
* Experimental APIs that are annotated with `@internal` and gated behind a feature flag.
* Internal-facing APIs shared across files within the same layer, when library-private declarations are impractical.
Comment thread
LongCatIsLooong marked this conversation as resolved.
Resolved

## Context

The style guide bans package-private APIs beyond library-private (`_`) declarations and requires every file in `lib/src/<layer>/` to be exported by `lib/<layer>.dart`. When an implementation detail is needed across multiple files within a layer, contributors are left with two poor options:

* Keep declarations library-private (`_`), which forces all related code into a single large file.
* Expose implementation details as public APIs (such as `NavigatorState.finalizeRoute` shared between `navigator.dart` and `routes.dart`, or `HardwareKeyboard.syncKeyboardState` shared between `hardware_keyboard.dart` and `binding.dart` in `services`), which commits the framework to backward compatibility and discourages refactoring.

@LongCatIsLooong LongCatIsLooong Nov 11, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which commits the framework to backward compatibility and discourages refactoring.

I feel this would encourage the framework to sit on tech debts instead, if @internal is not counted as tech debts (IIRC there's some sort of tracker for framework tech debts?)

If the API design is leaky and it's a real framework tech debt (the more common case), wouldn't improving the API and deprecating the existing leaky API be the more correct thing to do (especially if you're already refactoring it)?

If the API design is leaky because it has to be leaky (e.g., because of a language limitation), then I feel it's justified to tell API users that the API isn't really a part of the public contract and should not be used (and prevent dartdoc from generating a reference for that API).

NavigatorState.finalizeRoute

I think it's still part of the Route lifecycle contract? A custom Route subclass would still need to call this method in didPop. So this shouldn't really qualify for @internal?

HardwareKeyboard.syncKeyboardState

Not familiar with this one, but Gemini suggested the leak is avoidable if we redesign the API if we pass the initial key presses synchronously. So technically a tech debt?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we require a TODO comment for @internal usages for this category, since in most cases they are tech debts?

@chunhtai chunhtai Nov 12, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A custom Route subclass would still need to call this method in didPop. So this shouldn't really qualify for @internal?

they can do that by calling super, I think the reason is didpop can have animation, but that is self contained in TransitionRoute. One arguement is someone implement route interface, but it won't work without significant hack in navigator because there is internal logic in base route class.

The only use case I can think of that can't be solve by inheritance is that they implement on top of Route but below TransitionRoute, It will break a bunch of other stuff like gesture pop and predicative back. So I don't think that is something we should support.

I agree this may be able to be prevented through less leaky design, but that will mean we will build transition and animation in mind when designing navigator, or just move TransitionRoute to navigator.dart.

but that back to the question whether we are going to merge classes and different concepts in a single file or not.

Should we require a TODO comment for @internal usages for this category, since in most cases they are tech debts?

I think sometimes this is just unavoidable, and the alternative is big files. but I think requiring a comment reasoning may be a good practice


In practice, the framework already omits `_`-prefixed files in `lib/src/` from barrel exports (such as `lib/src/widgets/_accessibility_evaluations.dart`, `lib/src/widgets/_window.dart`, and `lib/src/foundation/_features.dart`), and `flutter/packages` already uses `@internal` with `hide` (such as `GoRouterStateRegistry` in `go_router`).

Relying on filename conventions alone risks accidental leaks. Because `lib/src/` files are re-exported by default, moving code or sharing a non-private class within a layer can inadvertently expose it to external consumers, making future edits breaking changes. Marking these declarations `@internal` triggers an [`invalid_export_of_internal_element`](https://dart.dev/tools/diagnostics/invalid_export_of_internal_element) analyzer error if exported from a public barrel file, and triggers [`invalid_use_of_internal_member`](https://dart.dev/tools/diagnostics/invalid_use_of_internal_member) if external consumers import `lib/src/` directly.

## Proposal

### Breaking change policy and customer tests

* Modifying, renaming, or removing any declaration in `lib/src/` that is not exported by a public barrel file and is annotated with `@internal` is not a breaking change and requires no deprecation period or announcement, subject to the grace period below.
* Adding `@internal` to an existing declaration must be accompanied by a comment that ends with `This feature was made internal after v<version>.`, where `<version>` is the latest Flutter version tag when the PR is written, following the same convention as `@Deprecated` messages (for example, `// This feature was made internal after v3.50.0-0.3.pre.`). Until a stable release that includes the annotation has shipped, changes to the declaration follow the normal breaking change policy. Declarations that are `@internal` from the start have no grace period.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Until a stable release that includes the annotation has shipped, changes to the declaration follow the normal breaking change policy.

This does not grant much of a grace period. Since a change on main ships to stable within 1 week to 3 months, the symbol loses protection in at most 3 months, which is much shorter than our deprecation policy (and shorter than the 1-year window we agreed on in #30 (comment)).

What's worse, we cannot keep the symbol exported in the barrel file during this grace period: adding @internal to a top-level declaration requires adding hide to lib/<layer>.dart in the same PR to avoid invalid_export_of_internal_element (and even on class members, invalid_use_of_internal_member is a warning rather than an info). That means external callers using the public barrel are already broken on day 1, and the grace period only protects callers that imported lib/src/ directly.

Combining my suggestions in #30 (comment), the process for making an existing public symbol @internal should be:

  • Day 1: Mark the symbol @Deprecated (following the standard deprecation and breaking-change process) while keeping it exported.
  • In a stable release at least {1 year for framework, 6 months for packages} later: Replace @Deprecated with @internal and add hide to the barrel export.
    With this process, @internal symbols no longer need a separate grace period or the // This feature was made internal after v<version>. comment convention.

* Registered tests in [`flutter/tests`](https://github.com/flutter/tests) must not import `package:flutter/src/` or depend on `@internal` APIs. Breakages in customer tests caused by `package:flutter/src/` imports or `@internal` usage are not breaking changes, and contributors may disable or update offending customer tests when modifying `@internal` declarations that are past any grace period.

### Package-private APIs (`@internal`)

Package-private APIs remain discouraged in general, rather than banned. Unexported files and package-private declarations in `lib/src/` are permitted only in the following cases:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the wording to discourage use of package private instead of banning.


* Experimental APIs that are not yet ready for public stability guarantees. These APIs must be both annotated with `@internal` and gated behind a feature flag.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I again suggest keeping experimental APIs out of this RFC, as I mentioned earlier. How experimental APIs should work is more than just a tag, and hasn't been settled in this thread: @internal + feature flag vs. @experimental, how users opt in, how APIs graduate, and whether SDK packages may use them (flutter_test/lib/src/binding.dart already uses the windowing APIs via a file-wide ignore_for_file). I'd suggest removing the experimental category here, and treating the existing windowing APIs and the _features.dart exception as out of scope until a separate RFC covers them.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@experimental is a weaker guard than @internal + feature flag. We also already have leaned toward package private + feature flag in our current code, this guideline change only makes it easier to follow for new experimental API. I view it as a net benefit.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that @internal + feature flag is a stronger guard than @experimental; I'm not arguing for @experimental. My concern is codifying the pattern before its rules are worked out.

The only existing case, windowing, doesn't follow the other rules in this RFC: WidgetsBinding.windowingOwner is an @internal instance member on an exported mixin, and flutter_test uses these APIs through a file-wide ignore_for_file. Once this lands, those become violations to fix or exceptions to carve out. Open questions like how users opt in (importing src/ plus an ignore?) and how an API graduates would get answered by whatever the first few cases happen to do, and changing a published guideline later is harder than getting it right once.

Since windowing is the only experimental API using this pattern today, deferring the category to a separate RFC costs little. If you'd rather keep it here, I'd at least like the RFC to cover how windowing conforms, how users are expected to opt in, and what graduation looks like.

@chunhtai chunhtai Nov 2, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the current rule in this rfc should have covered this? that the windowingOwner should have been internal extension and should be fixed.

Open questions like how users opt in (importing src/ plus an ignore?)

External package or app should not use it so there is no opt in for them even in their tests. Their only channel is to file an issue to open the API to public

If you meant the test in flutter/flutter, I am less concern about how they get around the @internal decorator. Either they direct import or use ignore_for_file.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving windowingOwner to an internal extension doesn't work technically: WidgetsBinding.createWindowingOwner() has to be overridden by TestWidgetsFlutterBinding in flutter_test (a separate package), and extension methods can't be overridden. And because @internal is strictly package-scoped, flutter_test has no way to touch windowing types without ignore_for_file.

More importantly, "external apps should not use it" contradicts why the API is behind a user-facing --enable-windowing flag in the first place. In fact, the windowingOwner error message, _window.dart doc comments, and examples/multiple_windows all explicitly instruct developers to enable the flag and import src/ (with ignore_for_file) to try it out before it stabilizes (flutter/flutter#177586).

Therefore applying @internal to windowingOwner, even with it converted to an extension, will not solve its problem.

Speaking more broadly, both issues come from the same root cause: @internal is for symbols that are never accessed outside the package, whereas an experimental API is meant to be tried across package boundaries before becoming public. Having an experimental API mechanism is valuable and worth discussing, but it shouldn't be built on @internal, and it involves process questions beyond syntax—such as the criteria and lifecycle for introducing and graduating an experimental API. For this RFC, we should remove the experimental category (leaving windowing as an existing exception for now) and address experimental APIs in a dedicated proposal.

* Internal-facing APIs shared across files within the same layer (`lib/src/<layer>/`). Contributors should use true library-private (`_`) declarations first, and only use `@internal` package-private APIs when keeping the code in one file would put too many distinct concepts in a single file.

#### Relationship to `@visibleForTesting` and `@Deprecated`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding this one:

  • @internal APIs must be used internally. An @internal API that is referenced nowhere in the codebase should be removed.

I think this helps distinguish it from @Deprecated as discussed in https://github.com/flutter/rfc/pull/30/changes#r4265258174.

If you agree, do you think it would be possible to enforce this in an automated way?


* `@internal` is for package-private declarations omitted or hidden from public barrel exports.
* `@internal` APIs must be used internally. An `@internal` API that is referenced nowhere in the package's non-test code (outside `test/`, `integration_test/`, and other test directories) should be removed. References from tests do not count as uses; test-only access is what `@visibleForTesting` is for.
* `@internal` must not be used on APIs meant to be removed; use `@Deprecated` instead.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rule relies on author intent, which can't be verified in review. As raised earlier in this thread, an exported API can be marked @internal (and hidden from the barrel) in one PR, and then deleted in a follow-up PR with no deprecation period. Even when the author genuinely intends to keep the API for internal use, hiding an existing symbol from the barrel breaks external callers immediately without the migration window that @Deprecated provides.

I'd suggest a mechanical rule instead: hiding an already-exported API from the barrel or marking it @internal counts as removing a public API and must go through the standard deprecation process first (deprecate the public API while migrating internal callers to a new private or @internal helper, then remove the deprecated public API after the deprecation period).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest a mechanical rule instead: hiding an already-exported API from the barrel or marking it @internal counts as removing a public API

It should be treated as breaking change for sure. The loop hole is that once that is done we don't have guideline says when it can be actually removed. so a bad actor can come in mark it internal (with breaking change notice) and remove it immediately after.

The deprecation policy does say we will wait at least a stable release before removing @deprecated API.

We could included that removing @internal API should also wait at least one stable, but then it still require reviewer to catch the bad actor. I felt saying more other than the current statement won't help much.

@dkwingsmt dkwingsmt Oct 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. Thanks for clarifying. Maybe we should rule that adding @internal to an existing API must be accompanied by a comment of the earliest date that this internal API might be broken, which is 1 year from the PR's landing date.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sg

* `@visibleForTesting` remains discouraged on public declarations, because declarations exported by a public barrel file remain reachable by external consumers without importing `lib/src/`.

#### Placement rules

* `@internal` may only be applied to declarations inside `lib/src/`.
* For package-private members on a publicly exported class, prefer an `@internal` `extension` declared in the same file, excluded from `lib/<layer>.dart` with `hide`, and with both the `extension` and all its members annotated with `@internal` (an explicit exception to the style guide rule against `extension`s).

For example, `lib/src/widgets/route_state.dart` can keep the implementation private while allowing another file in the layer to invoke it through an internal extension:

```dart
import 'package:meta/meta.dart';

class RouteState {
void _finalizeRoute() {
// Implementation.
}
}

@internal
extension RouteStateInternal on RouteState {
@internal
void finalizeRoute() => _finalizeRoute();
}
Comment on lines +79 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would try to avoid this kind of thing unless it's really necessary. It's probably a sign that finalizeRoute should just be public. But I guess I imagine there could be a situation where we do need it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, therefore I phrase it as discourage to use @internal as general.

For this particular case, it is for separating navigator and route files, the alternative will be merging them navigator.dart and route.dart.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with not allowing @internal on instance members of exported classes, but the @internal extension isn't a good workaround for letting other files in the package use a library-private (_) member of an exported class. A same-file @internal top-level function does the same job, since Dart privacy is library-level. Your Helper example in this thread relies on the same mechanism. Compared with the extension, it doesn't need an exception to the style guide rule against extensions, and it actually triggers invalid_use_of_internal_member for external callers. The extension doesn't, because the analyzer resolves the call to the unannotated extension member rather than to the extension.

Repro (external package importing src/ directly):

// package:lib_pkg/src/route_state.dart
import 'package:meta/meta.dart';

class RouteState {
  void _finalizeRoute() {}
}

@internal
extension RouteStateInternal on RouteState {
  void finalizeRoute() => _finalizeRoute();
}

extension RouteStateInternal2 on RouteState {
  @internal
  void finalizeRoute2() => _finalizeRoute();
}

@internal
void finalizeRoute3(RouteState s) => s._finalizeRoute();
// package:app/main.dart
import 'package:lib_pkg/src/route_state.dart';

void main() {
  final s = RouteState();
  s.finalizeRoute();                     // no diagnostic
  RouteStateInternal(s).finalizeRoute(); // no diagnostic
  s.finalizeRoute2();                    // invalid_use_of_internal_member
  finalizeRoute3(s);                     // invalid_use_of_internal_member
}

Suggestion: recommend the top-level function as the workaround. Whether to allow @internal extensions for other cases (e.g. helpers on types we don't own) is your call, but if they are allowed, @internal must go on each extension member, not only on the extension declaration.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is not answered yet. There is no need of making an exception of using extension for package privates, as they can be completely replaced by top-level functions. There is zero benefit of using extension except for a beautiful .foo() syntax, which has no value for internal functions.

```

The public barrel excludes the extension:

```dart
export 'src/widgets/route_state.dart' hide RouteStateInternal;
```

An internal file in the same layer can still import the source file and use the extension:

```dart
import 'route_state.dart';

void finish(RouteState state) {
state.finalizeRoute();
}
```

* When a package-private constructor or class member is needed for inheritance or construction on a publicly exported class, all such constructors and members must be annotated with `@internal`, and the class must be marked `final` or `sealed` so external classes cannot `extends` or `implements` it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rule contradicts itself on inheritance. A final or sealed class cannot be extended or implemented in any other file. Within the same file, _ declarations already work, so @internal is never needed there; across files, final and sealed forbid the inheritance this bullet tries to permit.

Assuming this rule is an answer to #30 (comment) : Constructors and static members do not need final or sealed. Because they aren't part of the class interface, changing them can't break external extends or implements, and requiring final or sealed needlessly prevents adding them to existing public classes. To allow in-package subclasses across files while blocking external subtypes, use base with a named @internal generative constructor (@internal Foo.internal(); unnamed @internal Foo() misses implicit super() calls).

* `@internal` types must not appear in the public signatures or supertypes (`extends`, `implements`, `with`, `on`) of publicly exported declarations, because the analyzer does not flag `@internal` types leaked indirectly through exported APIs. Expose a public type or supertype instead, using an `@internal` constructor if construction must remain package-private.
* In any file in `lib/src/` not exported by a barrel file, all public top-level declarations must be annotated with `@internal`, subject to the cross-platform API policy below.

#### Cross-platform APIs

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @loic-sharma and @justinmc , I added this section let me know what you think


**Open question:** Which approach should the guideline take for conditional-import implementations such as `_*_io.dart` and `_*_web.dart`? The preferred approach is option 2, which keeps platform-specific declarations internal and exposes the supported API through a public facade.

1. **Exception:** Keep existing declarations as-is and treat conditional-import implementation files as an explicit exception to the rule requiring `@internal` on public top-level declarations in unexported files.
2. **Public facade (preferred):** Annotate declarations in the implementation files with `@internal` and expose the supported API through a public facade that delegates to the selected implementation. For example:
Comment on lines +110 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 looks good to me

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also lean towards option 2. Defining public APIs inside _-prefixed files is counterintuitive: the _ prefix reads as package-internal, so contributors editing those files won't realize they are changing public API. I don't feel strongly, though.


```dart
// lib/src/widgets/device.dart
import '_device_stub.dart'
if (dart.library.io) '_device_io.dart'
if (dart.library.html) '_device_web.dart' as platform;

class Device {
Device() : _implementation = platform.DeviceImplementation();

final platform.DeviceImplementation _implementation;

String get name => _implementation.name;

bool get isAvailable => _implementation.isAvailable;

Future<void> refresh() => _implementation.refresh();
}
```

```dart
// lib/src/widgets/_device_stub.dart
import 'package:meta/meta.dart';

@internal
class DeviceImplementation {
String get name => 'Unknown device';

bool get isAvailable => false;

Future<void> refresh() async {}
}
```

```dart
// lib/src/widgets/_device_io.dart
import 'package:meta/meta.dart';

@internal
class DeviceImplementation {
String get name => 'Native device';

bool get isAvailable => true;

Future<void> refresh() async {
// Refresh native device state.
}
}
```

```dart
// lib/src/widgets/_device_web.dart
import 'package:meta/meta.dart';

@internal
class DeviceImplementation {
String get name => 'Web device';

bool get isAvailable => true;

Future<void> refresh() async {
// Refresh web device state.
}
}
```

The public barrel exports the facade, not the conditional implementations:

```dart
export 'src/widgets/device.dart';
```

Under the second approach, consumers use `Device`; the platform-specific implementations remain implementation details.

#### Barrel exports and file naming

* Drop the requirement that every file in `lib/src/<layer>/` be exported by `lib/<layer>.dart` (superseding [flutter/flutter#108632](https://github.com/flutter/flutter/issues/108632)).
* Any file in `lib/src/<layer>/` not exported by `lib/<layer>.dart` must have a `_` filename prefix (such as `lib/src/widgets/_accessibility_evaluations.dart`), and have all public top-level declarations annotated with `@internal`, subject to the cross-platform API policy above.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we automatically enforce this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, I think so.

* When an `@internal` top-level declaration shares a file with public declarations, `lib/<layer>.dart` must exclude it with `hide` (such as `export 'src/widgets/foo.dart' hide InternalBar;`). Omitting `hide` in `lib/<layer>.dart` triggers [`invalid_export_of_internal_element`](https://dart.dev/tools/diagnostics/invalid_export_of_internal_element). (`hide` is not required on intra-`src/` re-exports.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a valid use case for a file exposing both a public declaration and @internal, or should we force users to move the @internal stuff to a separate file?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a helper internal class that needs to access library private of a public class.

class PublicClass {
  final int _internal;
}

@internal
class Helper {
   void print(PublicClasss a) {
     print(a._internal);
   }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I guess that might be annoying to split into a separate file.

* Cross-layer imports in `lib/src/` must continue importing top-level barrel files (`package:flutter/<layer>.dart`) rather than another layer's `lib/src/` files, with `lib/src/foundation/_features.dart` as the only permitted exception for framework-wide feature flags.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing to consider enforcing automatically if we can

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sg


### TODOs

The analyzer already catches direct exports of `@internal` declarations from barrel files with [`invalid_export_of_internal_element`](https://dart.dev/tools/diagnostics/invalid_export_of_internal_element), and external usage with [`invalid_use_of_internal_member`](https://dart.dev/tools/diagnostics/invalid_use_of_internal_member) and `implementation_imports`. The remaining work:

1. Update the [Flutter repository style guide](https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md) and [breaking change policy](https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes) to match this proposal, replacing the current ban on package-private APIs and the requirement that every file in `lib/src/<layer>/` be exported by `lib/<layer>.dart`.
2. Add custom analyzer lint rules for single-library repository rules:
* In `_`-prefixed files in `lib/src/`, all public top-level declarations must be annotated with `@internal` (subject to the cross-platform API policy above).
* `@internal` types must not appear in the public signatures or supertypes (`extends`, `implements`, `with`, `on`) of publicly exported declarations.
* Public members of an `@internal` `extension` must also be annotated with `@internal`.
* Any publicly exported class with `@internal` constructors or members must be marked `final` or `sealed`.
* Files in `lib/src/<layer>/` must not import another layer's `lib/src/` files directly, except for `lib/src/foundation/_features.dart`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with lib/src/foundation/_features.dart as the only permitted exception

_features.dart isn't the only cross-layer import of an unexported file today. There are at least two more:

  • lib/src/web.dart: a hand-copied subset of package:web (DOM and JS interop types such as HTMLImageElement and document), kept in the framework so that Flutter doesn't depend on package:web. It sits directly in lib/src/, outside any layer directory, and isn't exported by any barrel. It's imported via ../web.dart by web implementations in three layers: foundation/_error_dumper_web.dart, painting/_web_image_info_web.dart and painting/_network_image_web.dart, and widgets/_web_image_web.dart and widgets/_platform_selectable_region_context_menu_web.dart.
  • lib/src/painting/_web_image_info_{io,web}.dart: defines WebImageInfo. It isn't exported by painting.dart, but the widgets layer imports it directly: widgets/image.dart (as a conditional import) and widgets/_web_image_{io,web}.dart. It can't simply move to the widgets layer, because it's created in the painting layer (painting/_network_image_web.dart, behind NetworkImage) and consumed in the widgets layer (Image checks for it to switch to an <img> platform view). Making it public isn't trivial either, since its htmlImage getter returns the HTMLImageElement type from the web.dart shim above.

I think this rule is still valuable, since keeping cross-layer "private imports" to a minimum is crucial for layer isolation, and for letting external code build on the lower layers (such as rendering) the same way the framework's own higher layers do. I suggest adding these two as exceptions for now. web.dart should probably be a permanent exception. WebImageInfo might be worth another look in the future, to see whether we can factor it out or turn it into a meaningful public class: today, a third-party image widget built on NetworkImage receives a WebImageInfo on the web but has no way to recognize it, which is exactly the kind of gap this rule is meant to prevent.

3. Add CI checks for cross-file and barrel rules:
* Any file in `lib/src/<layer>/` not exported by `lib/<layer>.dart` must have a `_` filename prefix, and `_`-prefixed files must not be exported by `lib/<layer>.dart`.

@dkwingsmt dkwingsmt Nov 13, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any file in lib/src/<layer>/ not exported by lib/<layer>.dart must have a _ filename prefix

As I mentioned in #30 (comment), it's sufficient to use @internal as the single source of truth, and there's no longer a technical need to require a _ filename prefix for unexported files.

While I agree that this convention can be a helpful visual cue, it has a practical cost:

  • If an unexported file gains its first public declaration, it has to be renamed to drop the _ before it can be exported.
  • If an exported file loses its last public declaration, we either have to rename it with a _ prefix, or keep exporting it with every remaining @internal declaration listed in hide (which this doc allows).

Renaming a file is disruptive: every import of it has to be updated, links to it break, its history is lost from git log without --follow, and it moves around in file listings. That cost isn't justified just to keep the filename in sync with information that @internal already carries. The analyzer and the CI check can work off @internal and the barrel's export list directly, without looking at filenames.

I'd suggest dropping the filename requirement, and keeping the _ prefix only as the existing convention for conditional-import implementation files.

* Every `@internal` declaration must be referenced in the package's non-test code; references from tests do not count, and unused `@internal` declarations must be removed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I wonder if we need to make an exception to generated code, such as those in pigeon, which generates functions for all APIs regardless of whether they're used. cc @tarrinneal

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pigeon shouldn't need an exception, but I don't know about the jnigen/ffigen output.

4. Migrate existing cross-platform code (conditional-import `_*_io.dart` and `_*_web.dart` implementations) to the public facade approach.
5. Audit existing files that contain too many distinct concepts, such as `lib/src/widgets/framework.dart` and `lib/src/widgets/basic.dart`, and split them into smaller files using `@internal` where cross-file access is needed.
Loading