← Back to Blog

Split app_en.arb Into Feature ARB Files, Then Merge

flutterarbgen-l10nlocalizationtooling

Split app_en.arb Into Feature ARB Files, Then Merge

At around 300 keys, a single app_en.arb stops being a file and becomes a merge-conflict machine. Two people touch checkout strings in the same sprint, and git hands you a conflict in a 4,000-line JSON blob where every line looks the same. The obvious fix is one ARB per feature. The obvious fix does not work, because flutter gen-l10n accepts exactly one ARB file per locale.

This post shows the workaround that teams actually ship: keep your per-feature ARBs as the real source of truth, and generate the single app_<locale>.arb that gen-l10n reads with a ~40-line Dart script wired into your build.

Why flutter multiple ARB files still isn't a thing

Put two *_en.arb files in your arb-dir and the tool stops:

Multiple arb files with the same locale detected.
Ensure that there is exactly one arb file for each locale.

gen-l10n derives a locale from each file (filename suffix or the @@locale key) and builds a map keyed by locale. Two entries for en is an error, not a merge.

The feature request is flutter/flutter#107157, "Allow multiple arb files to organize l10n / intl localizations for a language". It is open, labelled P2 with has partial patch, and has been in that state for years. #153087, which asked for the same thing with wildcard template-arb-file support, was closed as a duplicate of it. Nothing has landed. Plan around the current behaviour rather than waiting.

The layout

Sources go in a directory gen-l10n never looks at. Generated merges go in arb-dir.

lib/l10n/
  src/                      <- hand-edited, committed
    auth_en.arb
    auth_fr.arb
    checkout_en.arb
    checkout_fr.arb
    settings_en.arb
    settings_fr.arb
  app_en.arb                <- generated, gitignored
  app_fr.arb                <- generated, gitignored
  app_localizations.dart    <- gen-l10n output

Two things make this safe. gen-l10n reads .arb files sitting directly in arb-dir; it does not walk subdirectories, so lib/l10n/src/ is invisible to it. And the merged files are gitignored, which means nobody can hand-edit one and lose the change on the next build.

.gitignore:

lib/l10n/app_*.arb
lib/l10n/app_localizations*.dart

Naming convention for sources: <feature>_<locale>.arb. The script parses the locale off the end, so checkout_pt_BR.arb gives pt_BR.

A source file looks exactly like a normal ARB, metadata and all:

{
  "@@locale": "en",
  "checkoutTitle": "Checkout",
  "@checkoutTitle": {
    "description": "App bar title on the payment screen"
  },
  "checkoutItemCount": "{count, plural, =0{Empty} =1{1 item} other{{count} items}}",
  "@checkoutItemCount": {
    "description": "Number of items in the basket",
    "placeholders": {
      "count": { "type": "int" }
    }
  }
}

The merge script

Drop this at tool/merge_arb.dart. No dependencies beyond the SDK, so it runs with plain dart run.

// tool/merge_arb.dart
import 'dart:convert';
import 'dart:io';

const sourceDir = 'lib/l10n/src';
const outputDir = 'lib/l10n';

void main() {
  final byLocale = <String, List<File>>{};
  for (final entity in Directory(sourceDir).listSync(recursive: true)) {
    if (entity is! File || !entity.path.endsWith('.arb')) continue;
    byLocale.putIfAbsent(_localeOf(entity), () => []).add(entity);
  }

  byLocale.forEach((locale, files) {
    files.sort((a, b) => a.path.compareTo(b.path)); // stable output, clean diffs
    final merged = <String, dynamic>{'@@locale': locale};
    final owner = <String, String>{};

    for (final file in files) {
      final json = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
      for (final entry in json.entries) {
        if (entry.key.startsWith('@@')) continue; // @@locale, @@last_modified
        if (owner.containsKey(entry.key)) {
          stderr.writeln('Duplicate "${entry.key}" in $locale:');
          stderr.writeln('  ${owner[entry.key]}');
          stderr.writeln('  ${file.path}');
          exit(1);
        }
        owner[entry.key] = file.path;
        merged[entry.key] = entry.value;
      }
    }

    final out = File('$outputDir/app_$locale.arb');
    out.writeAsStringSync(
      '${const JsonEncoder.withIndent('  ').convert(merged)}\n',
    );
    stdout.writeln('${files.length} files -> ${out.path} '
        '(${owner.length} entries)');
  });
}

String _localeOf(File file) {
  final name = file.uri.pathSegments.last.replaceAll('.arb', '');
  final underscore = name.indexOf('_');
  if (underscore == -1) {
    throw FormatException(
      'No locale in ${file.path}; name files <feature>_<locale>.arb',
    );
  }
  return name.substring(underscore + 1);
}

Four behaviours worth calling out:

@@locale is written once, at the top. Per-file @@locale keys are skipped by the @@ guard, so you never get a duplicate or a stale value. The merged file starts with the locale the filenames agreed on, which is also what stops gen-l10n from misreading it.

@key metadata blocks come along untouched. @checkoutTitle is just another map entry. Descriptions and placeholders blocks land in the merged file byte-identical, so ICU plurals, select, and typed placeholders keep working.

Duplicates fail loudly. If auth_en.arb and settings_en.arb both define ok, the script prints both paths and exits 1. Silent last-write-wins is the failure mode that costs a day of debugging, so don't allow it. The check covers @ok too, since a metadata block collision is the same bug.

Output is deterministic. Files sorted by path, JsonEncoder.withIndent(' '), trailing newline. If you ever decide to commit the merged file instead of ignoring it, diffs stay readable.

Wiring it as a pre-build step

Never run flutter gen-l10n directly again. Run the pair:

dart run tool/merge_arb.dart && flutter gen-l10n

In a Makefile:

l10n:
	dart run tool/merge_arb.dart
	flutter gen-l10n

run: l10n
	flutter run

In CI, put it before analyze so a duplicate key breaks the pipeline rather than a release build:

- run: dart run tool/merge_arb.dart
- run: flutter gen-l10n
- run: flutter analyze

One gotcha with generate: true in pubspec.yaml: the tool regenerates localizations during a build or hot restart, but it regenerates from the merged file on disk. Edit a source ARB and hot restart, and you get the old strings, because nothing re-merged. Either run make l10n after touching strings, or add a --watch flag to the script using Directory(sourceDir).watch(recursive: true) and keep it running next to flutter run.

The l10n.yaml that goes with it

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
nullable-getter: false

Note what is missing: synthetic-package. Generated localizations moved out of the synthetic package:flutter_gen and into your source tree (landed in 3.28.0-0.0.pre, stable in 3.32.0), and recent gen-l10n versions reject the key outright. Imports are now relative to your project, for example import 'l10n/app_localizations.dart'; from lib/main.dart. If your config still carries synthetic-package: true, that is a separate migration to do first. Our l10n.yaml configuration guide walks through every option, and the l10n.yaml generator will build a correct one for you.

required-resource-attributes: true is tempting here and worth thinking about. It forces every key to carry a @key description, which is genuinely useful once strings are spread across a dozen feature files and nobody remembers what authRetryLabel was for.

What this buys you

A feature team owns lib/l10n/src/checkout_*.arb and nothing else. Conflicts collapse to the rare case where two people edit the same feature's strings. Code review shows a 6-line diff in a file named after the feature instead of a hunk at line 2,840 of a JSON blob. Deleting a feature means deleting its ARB files, and the merge script proves no other feature was leaning on those keys.

The cost is one script and one build step. That is a much smaller price than the one you pay waiting on #107157.

Where translators fit in

The merge script solves the developer side. It does not solve the part where a translator has to open checkout_fr.arb in a text editor and not break the ICU plural syntax. That is where a proper ARB editor earns its place: FlutterLocalisation gives you a UI over your app_<locale>.arb files instead of raw JSON, translation management across every locale you ship, and ICU plural validation that flags a locale missing a category its language actually requires, the dropped few for Polish or many for Russian that only shows up as a runtime error in production.

Try FlutterLocalisation free and keep the per-feature ARBs your team edits by hand, while the merged file stays exactly what it should be: generated, gitignored, and never opened.