DEV Community

Cover image for Backend-Driven Localization in Flutter: A Production-Ready Implementation Guide
Anurag Dubey
Anurag Dubey

Posted on

Backend-Driven Localization in Flutter: A Production-Ready Implementation Guide

Every app that goes global faces the same challenge: how do you update translations without forcing users to download a new version? Traditional ARB file-based localization in Flutter works well for small projects, but the moment you need to fix a typo in your Spanish translation or add support for a new market, you're stuck waiting for app store approval.

This implementation solves that problem by moving translations to a backend API. Your marketing team can update copy in real-time, you can A/B test different phrasings, and users always see the latest content without any app updates. The system includes offline support through intelligent caching, bandwidth optimization with HTTP 304 handling, and full RTL language support for markets like Arabic.

By the end of this guide, you'll have a production-ready localization system built with Clean Architecture principles and the BLoC pattern. The complete source code is available in this repository.


What We're Building

The localization system supports six languages out of the box: English, Spanish, French, German, Hindi, and Arabic. Each translation set includes over 50 keys covering common UI elements like buttons, form labels, error messages, and navigation items.

The architecture follows a specific flow: when the app launches, it checks for cached translations. If they exist, it sends a conditional request to the server with the cached version number. The server either returns new translations (HTTP 200) or confirms the cache is still valid (HTTP 304). This approach minimizes bandwidth usage while ensuring users always have current content.

For offline scenarios, the app falls back to cached translations automatically. Users can switch languages instantly because translations are pre-fetched and stored locally.


Project Structure

The codebase follows Clean Architecture with three distinct layers, each with a specific responsibility:

lib/
├── core/
│   ├── config/
│   │   ├── api_config.dart
│   │   └── app_strings.dart
│   ├── constants/
│   │   └── supported_locales.dart
│   ├── di/
│   │   └── injection_container.dart
│   ├── error/
│   │   ├── exceptions.dart
│   │   └── failures.dart
│   ├── network/
│   │   └── network_info.dart
│   └── theme/
│       └── app_theme.dart
│
└── features/
    └── localization/
        ├── data/
        │   ├── datasources/
        │   │   ├── localization_remote_datasource.dart
        │   │   └── localization_local_datasource.dart
        │   ├── models/
        │   │   └── translation_model.dart
        │   └── repositories/
        │       └── localization_repository_impl.dart
        │
        ├── domain/
        │   ├── entities/
        │   │   └── translation_entity.dart
        │   ├── repositories/
        │   │   └── localization_repository.dart
        │   └── usecases/
        │       ├── get_translations_usecase.dart
        │       ├── change_locale_usecase.dart
        │       └── get_supported_locales_usecase.dart
        │
        └── presentation/
            ├── bloc/
            │   ├── localization_bloc.dart
            │   ├── localization_event.dart
            │   └── localization_state.dart
            ├── pages/
            │   ├── home_page.dart
            │   └── settings_page.dart
            └── widgets/
                └── language_selector.dart
Enter fullscreen mode Exit fullscreen mode

The domain layer contains pure business logic with no external dependencies. It defines what the app needs through abstract interfaces. The data layer implements those interfaces using Dio for API calls and Hive for local storage. The presentation layer uses BLoC to manage UI state and respond to user interactions.


Dependencies

Add these packages to your pubspec.yaml:

dependencies:
  connectivity_plus: ^6.1.0
  dartz: ^0.10.1
  dio: ^5.7.0
  equatable: ^2.0.5
  flutter:
    sdk: flutter
  flutter_bloc: ^9.1.1
  flutter_localizations:
    sdk: flutter
  get_it: ^7.7.0
  hive_flutter: ^1.1.0
  intl: ^0.20.2
  shared_preferences: ^2.3.3
Enter fullscreen mode Exit fullscreen mode

Each package serves a specific purpose: dio handles HTTP requests with built-in interceptor support, hive_flutter provides fast local storage for cached translations, flutter_bloc manages state predictably, get_it serves as a lightweight dependency injection container, and dartz enables functional error handling with the Either type.


Android Configuration

Enable network access in android/app/src/main/AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET"/>

    <application
        android:label="flutter_blog"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher"
        android:usesCleartextTraffic="true">
        <!-- Activity configuration -->
    </application>
</manifest>
Enter fullscreen mode Exit fullscreen mode

The usesCleartextTraffic="true" attribute allows HTTP connections during development. Remove this in production where all traffic should use HTTPS.


Core Layer: Building the Foundation

API Configuration

The API config centralizes all endpoint definitions and provides different base URLs for various development environments:

/// lib/core/config/api_config.dart

class ApiConfig {
  ApiConfig._();

  /// Android emulator maps 10.0.2.2 to host's localhost
  static const String androidEmulatorUrl = 'http://10.0.2.2:3000/api/v1';

  /// Direct localhost for iOS Simulator and desktop
  static const String localhostUrl = 'http://localhost:3000/api/v1';

  /// Your machine's WiFi IP for physical device testing
  static const String physicalDeviceUrl = 'http://192.168.0.103:3000/api/v1';

  /// Switch this based on your testing environment
  static const String baseUrl = physicalDeviceUrl;

  static const String translations = '/translations';
  static const String translationsByLocale = '/translations/{locale}';
  static const String supportedLocales = '/translations/supported-locales';

  static const Duration connectTimeout = Duration(seconds: 30);
  static const Duration receiveTimeout = Duration(seconds: 30);
  static const Duration sendTimeout = Duration(seconds: 30);

  static Map<String, String> get defaultHeaders => {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      };
}
Enter fullscreen mode Exit fullscreen mode

A common issue when testing on Android emulators: requests to localhost:3000 fail because the emulator's localhost points to itself, not your development machine. The special IP 10.0.2.2 solves this by mapping to the host's loopback interface.

Exception and Failure Types

The error handling system uses two levels: exceptions for immediate errors and failures for domain-level error representation.

/// lib/core/error/exceptions.dart

class ServerException implements Exception {
  final String message;
  final int? statusCode;
  final dynamic responseBody;

  const ServerException({
    required this.message,
    this.statusCode,
    this.responseBody,
  });
}

class CacheException implements Exception {
  final String message;
  const CacheException({this.message = 'Cache operation failed'});
}

/// Special exception for HTTP 304 Not Modified responses
class CacheValidException implements Exception {
  const CacheValidException();
}
Enter fullscreen mode Exit fullscreen mode

The CacheValidException deserves attention. When the server returns HTTP 304, it means our cached data is still current. This isn't an error condition - it's actually good news. We throw this exception to signal that the repository should use cached data instead of treating it as a failure.

/// lib/core/error/failures.dart

abstract class Failure extends Equatable {
  final String message;
  final String? code;

  const Failure({required this.message, this.code});

  @override
  List<Object?> get props => [message, code];
}

class ServerFailure extends Failure {
  final int? statusCode;

  const ServerFailure({
    required super.message,
    super.code,
    this.statusCode,
  });
}

class NetworkFailure extends Failure {
  const NetworkFailure({
    super.message = 'No internet connection. Please check your network.',
    super.code = 'NETWORK_ERROR',
  });
}

class CacheFailure extends Failure {
  const CacheFailure({
    super.message = 'Failed to access local cache.',
    super.code = 'CACHE_ERROR',
  });
}

class LocalizationFailure extends Failure {
  final String? locale;

  const LocalizationFailure({
    required super.message,
    super.code = 'LOCALIZATION_ERROR',
    this.locale,
  });
}
Enter fullscreen mode Exit fullscreen mode

Network Connectivity

The network info service wraps connectivity_plus to provide a clean interface for checking network status:

/// lib/core/network/network_info.dart

abstract class NetworkInfo {
  Future<bool> get isConnected;
  Stream<bool> get onConnectivityChanged;
}

class NetworkInfoImpl implements NetworkInfo {
  final Connectivity _connectivity;

  NetworkInfoImpl(this._connectivity);

  @override
  Future<bool> get isConnected async {
    final result = await _connectivity.checkConnectivity();
    return !result.contains(ConnectivityResult.none);
  }

  @override
  Stream<bool> get onConnectivityChanged {
    return _connectivity.onConnectivityChanged.map(
      (result) => !result.contains(ConnectivityResult.none),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Domain Layer: Pure Business Logic

Translation Entity

The core entity represents a complete translation set for a locale:

/// lib/features/localization/domain/entities/translation_entity.dart

class TranslationEntity extends Equatable {
  final String locale;
  final String version;
  final DateTime updatedAt;
  final Map<String, String> translations;

  const TranslationEntity({
    required this.locale,
    required this.version,
    required this.updatedAt,
    required this.translations,
  });

  factory TranslationEntity.empty(String locale) {
    return TranslationEntity(
      locale: locale,
      version: '0.0.0',
      updatedAt: DateTime.now(),
      translations: const {},
    );
  }

  String get(String key, [Map<String, dynamic>? params]) {
    String value = translations[key] ?? key;

    if (params != null) {
      params.forEach((paramKey, paramValue) {
        value = value.replaceAll('{$paramKey}', paramValue.toString());
      });
    }

    return value;
  }

  @override
  List<Object?> get props => [locale, version, updatedAt, translations];
}
Enter fullscreen mode Exit fullscreen mode

The version field is crucial for cache invalidation. When fetching translations, the app sends its current cached version to the server. If the server has a newer version, it returns the full translation set. If not, it returns HTTP 304.

Repository Interface

The repository interface defines what operations the app needs without specifying how they're implemented:

/// lib/features/localization/domain/repositories/localization_repository.dart

abstract class LocalizationRepository {
  Future<Either<Failure, TranslationEntity>> getTranslations(
    String locale, {
    bool forceRefresh = false,
  });

  Future<Either<Failure, SupportedLocalesEntity>> getSupportedLocales();

  Future<Either<Failure, bool>> cacheTranslations(TranslationEntity translations);

  Future<Either<Failure, TranslationEntity>> getCachedTranslations(String locale);

  Future<String?> getCachedVersion(String locale);

  Future<Either<Failure, bool>> clearCache();

  Future<Either<Failure, bool>> savePreferredLocale(String locale);

  Future<String?> getPreferredLocale();
}
Enter fullscreen mode Exit fullscreen mode

Using Either<Failure, T> from dartz forces explicit error handling. Every caller must handle both success and failure cases - you can't accidentally ignore errors.

Use Cases

Each use case encapsulates a single business operation:

/// lib/features/localization/domain/usecases/get_translations_usecase.dart

class GetTranslationsUseCase {
  final LocalizationRepository _repository;

  const GetTranslationsUseCase(this._repository);

  Future<Either<Failure, TranslationEntity>> call(
    GetTranslationsParams params,
  ) async {
    return _repository.getTranslations(
      params.locale,
      forceRefresh: params.forceRefresh,
    );
  }
}

class GetTranslationsParams extends Equatable {
  final String locale;
  final bool forceRefresh;

  const GetTranslationsParams({
    required this.locale,
    this.forceRefresh = false,
  });

  @override
  List<Object?> get props => [locale, forceRefresh];
}
Enter fullscreen mode Exit fullscreen mode
/// lib/features/localization/domain/usecases/change_locale_usecase.dart

class ChangeLocaleUseCase {
  final LocalizationRepository _repository;

  const ChangeLocaleUseCase(this._repository);

  Future<Either<Failure, TranslationEntity>> call(
    ChangeLocaleParams params,
  ) async {
    final translationsResult = await _repository.getTranslations(params.locale);

    return translationsResult.fold(
      (failure) => Left(failure),
      (translations) async {
        await _repository.savePreferredLocale(params.locale);
        await _repository.cacheTranslations(translations);
        return Right(translations);
      },
    );
  }
}

class ChangeLocaleParams extends Equatable {
  final String locale;

  const ChangeLocaleParams({required this.locale});

  @override
  List<Object?> get props => [locale];
}
Enter fullscreen mode Exit fullscreen mode

Data Layer: API and Cache Implementation

Remote Data Source

The remote data source handles all API communication. Pay close attention to the HTTP 304 handling:

/// lib/features/localization/data/datasources/localization_remote_datasource.dart

class LocalizationRemoteDataSourceImpl implements LocalizationRemoteDataSource {
  final Dio _dio;

  const LocalizationRemoteDataSourceImpl(this._dio);

  @override
  Future<TranslationModel> getTranslations(
    String locale, {
    String? currentVersion,
  }) async {
    try {
      final queryParams = <String, dynamic>{};
      if (currentVersion != null) {
        queryParams['current_version'] = currentVersion;
      }

      final response = await _dio.get(
        '/translations/$locale',
        queryParameters: queryParams.isNotEmpty ? queryParams : null,
        options: Options(
          // This is the key: accept 304 as a valid status
          validateStatus: (status) => status != null && (status < 400 || status == 304),
        ),
      );

      // Handle 304 Not Modified
      if (response.statusCode == 304) {
        throw const CacheValidException();
      }

      return TranslationModel.fromJson(response.data as Map<String, dynamic>);
    } on CacheValidException {
      rethrow;
    } on DioException catch (e) {
      if (e.response?.statusCode == 304) {
        throw const CacheValidException();
      }
      throw ServerException(
        message: e.message ?? 'Failed to fetch translations',
        statusCode: e.response?.statusCode,
        responseBody: e.response?.data,
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

By default, Dio treats any non-2xx status code as an error. The validateStatus option tells Dio to accept 304 as a valid response. Without this, Dio would throw an exception before we could check the status code.

Local Data Source

The local data source uses Hive for caching and SharedPreferences for simple key-value storage:

/// lib/features/localization/data/datasources/localization_local_datasource.dart

class LocalizationLocalDataSourceImpl implements LocalizationLocalDataSource {
  static const String _translationsBoxName = 'translations_cache';
  static const String _preferredLocaleKey = 'preferred_locale';

  final SharedPreferences _sharedPreferences;
  Box<String>? _translationsBox;

  LocalizationLocalDataSourceImpl(this._sharedPreferences);

  Future<Box<String>> get _box async {
    if (_translationsBox != null && _translationsBox!.isOpen) {
      return _translationsBox!;
    }
    _translationsBox = await Hive.openBox<String>(_translationsBoxName);
    return _translationsBox!;
  }

  @override
  Future<void> cacheTranslations(TranslationModel translations) async {
    try {
      final box = await _box;
      final jsonString = jsonEncode(translations.toJson());
      await box.put(translations.locale, jsonString);
    } catch (e) {
      throw CacheException(message: 'Failed to cache translations: $e');
    }
  }

  @override
  Future<TranslationModel> getCachedTranslations(String locale) async {
    try {
      final box = await _box;
      final jsonString = box.get(locale);

      if (jsonString == null) {
        throw CacheException(
          message: 'No cached translations found for locale: $locale',
        );
      }

      final json = jsonDecode(jsonString) as Map<String, dynamic>;
      return TranslationModel.fromJson(json);
    } catch (e) {
      if (e is CacheException) rethrow;
      throw CacheException(message: 'Failed to read cached translations: $e');
    }
  }

  @override
  Future<String?> getCachedVersion(String locale) async {
    try {
      final box = await _box;
      final jsonString = box.get(locale);

      if (jsonString == null) return null;

      final json = jsonDecode(jsonString) as Map<String, dynamic>;
      return json['version'] as String?;
    } catch (e) {
      return null;
    }
  }

  @override
  Future<void> savePreferredLocale(String locale) async {
    await _sharedPreferences.setString(_preferredLocaleKey, locale);
  }

  @override
  Future<String?> getPreferredLocale() async {
    return _sharedPreferences.getString(_preferredLocaleKey);
  }
}
Enter fullscreen mode Exit fullscreen mode

Repository Implementation

The repository coordinates between remote and local data sources, implementing the offline-first strategy:

/// lib/features/localization/data/repositories/localization_repository_impl.dart

class LocalizationRepositoryImpl implements LocalizationRepository {
  final LocalizationRemoteDataSource _remoteDataSource;
  final LocalizationLocalDataSource _localDataSource;
  final NetworkInfo _networkInfo;

  const LocalizationRepositoryImpl({
    required LocalizationRemoteDataSource remoteDataSource,
    required LocalizationLocalDataSource localDataSource,
    required NetworkInfo networkInfo,
  })  : _remoteDataSource = remoteDataSource,
        _localDataSource = localDataSource,
        _networkInfo = networkInfo;

  @override
  Future<Either<Failure, TranslationEntity>> getTranslations(
    String locale, {
    bool forceRefresh = false,
  }) async {
    final isConnected = await _networkInfo.isConnected;

    if (isConnected) {
      return _getRemoteTranslations(locale, forceRefresh: forceRefresh);
    } else {
      return _getCachedOrFail(locale);
    }
  }

  Future<Either<Failure, TranslationEntity>> _getRemoteTranslations(
    String locale, {
    bool forceRefresh = false,
  }) async {
    try {
      String? currentVersion;
      if (!forceRefresh) {
        currentVersion = await _localDataSource.getCachedVersion(locale);
      }

      final remoteTranslations = await _remoteDataSource.getTranslations(
        locale,
        currentVersion: currentVersion,
      );

      await _localDataSource.cacheTranslations(remoteTranslations);

      return Right(remoteTranslations);
    } on CacheValidException {
      // Server returned 304 - cached data is still valid
      return _getCachedOrFail(locale);
    } on DioException catch (e) {
      if (e.type == DioExceptionType.connectionTimeout ||
          e.type == DioExceptionType.receiveTimeout ||
          e.type == DioExceptionType.connectionError) {
        return _getCachedOrFail(locale);
      }

      return Left(
        ServerFailure(
          message: e.message ?? 'Failed to fetch translations',
          statusCode: e.response?.statusCode,
        ),
      );
    } on ServerException catch (e) {
      return Left(
        ServerFailure(
          message: e.message,
          statusCode: e.statusCode,
        ),
      );
    } catch (e) {
      return _getCachedOrFail(locale);
    }
  }

  Future<Either<Failure, TranslationEntity>> _getCachedOrFail(
    String locale,
  ) async {
    try {
      final cachedTranslations =
          await _localDataSource.getCachedTranslations(locale);
      return Right(cachedTranslations);
    } on CacheException catch (e) {
      return Left(
        LocalizationFailure(
          message: e.message,
          locale: locale,
        ),
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The key insight here is the CacheValidException catch block. When the server returns 304, we don't treat it as an error - we simply return the cached data. This is both efficient (no unnecessary data transfer) and correct (the user gets valid translations).


Presentation Layer: BLoC State Management

Events and States

The BLoC uses sealed classes for type-safe event and state handling:

/// lib/features/localization/presentation/bloc/localization_event.dart

sealed class LocalizationEvent extends Equatable {
  const LocalizationEvent();

  @override
  List<Object?> get props => [];
}

final class InitializeLocalizationEvent extends LocalizationEvent {
  const InitializeLocalizationEvent();
}

final class LoadTranslationsEvent extends LocalizationEvent {
  final String locale;
  final bool forceRefresh;

  const LoadTranslationsEvent({
    required this.locale,
    this.forceRefresh = false,
  });

  @override
  List<Object?> get props => [locale, forceRefresh];
}

final class ChangeLocaleEvent extends LocalizationEvent {
  final String locale;

  const ChangeLocaleEvent({required this.locale});

  @override
  List<Object?> get props => [locale];
}

final class RefreshTranslationsEvent extends LocalizationEvent {
  const RefreshTranslationsEvent();
}
Enter fullscreen mode Exit fullscreen mode
/// lib/features/localization/presentation/bloc/localization_state.dart

sealed class LocalizationState extends Equatable {
  const LocalizationState();

  @override
  List<Object?> get props => [];
}

final class LocalizationInitial extends LocalizationState {
  const LocalizationInitial();
}

final class LocalizationLoading extends LocalizationState {
  final String? message;

  const LocalizationLoading({this.message});

  @override
  List<Object?> get props => [message];
}

final class LocalizationLoaded extends LocalizationState {
  final Locale locale;
  final TranslationEntity translations;
  final List<String> supportedLocales;

  const LocalizationLoaded({
    required this.locale,
    required this.translations,
    this.supportedLocales = const ['en', 'es', 'fr', 'de', 'hi', 'ar'],
  });

  LocalizationLoaded copyWith({
    Locale? locale,
    TranslationEntity? translations,
    List<String>? supportedLocales,
  }) {
    return LocalizationLoaded(
      locale: locale ?? this.locale,
      translations: translations ?? this.translations,
      supportedLocales: supportedLocales ?? this.supportedLocales,
    );
  }

  @override
  List<Object?> get props => [locale, translations, supportedLocales];
}

final class LocalizationError extends LocalizationState {
  final String message;
  final String? code;
  final TranslationEntity? fallbackTranslations;
  final Locale? fallbackLocale;

  const LocalizationError({
    required this.message,
    this.code,
    this.fallbackTranslations,
    this.fallbackLocale,
  });

  @override
  List<Object?> get props => [message, code, fallbackTranslations, fallbackLocale];
}

final class LocaleChanging extends LocalizationState {
  final Locale fromLocale;
  final Locale toLocale;

  const LocaleChanging({
    required this.fromLocale,
    required this.toLocale,
  });

  @override
  List<Object?> get props => [fromLocale, toLocale];
}
Enter fullscreen mode Exit fullscreen mode

The BLoC

The BLoC handles all localization logic and coordinates between use cases:

/// lib/features/localization/presentation/bloc/localization_bloc.dart

class LocalizationBloc extends Bloc<LocalizationEvent, LocalizationState> {
  final GetTranslationsUseCase _getTranslationsUseCase;
  final ChangeLocaleUseCase _changeLocaleUseCase;
  final GetSupportedLocalesUseCase _getSupportedLocalesUseCase;
  final LocalizationRepository _repository;

  LocalizationBloc({
    required GetTranslationsUseCase getTranslationsUseCase,
    required ChangeLocaleUseCase changeLocaleUseCase,
    required GetSupportedLocalesUseCase getSupportedLocalesUseCase,
    required LocalizationRepository repository,
  })  : _getTranslationsUseCase = getTranslationsUseCase,
        _changeLocaleUseCase = changeLocaleUseCase,
        _getSupportedLocalesUseCase = getSupportedLocalesUseCase,
        _repository = repository,
        super(const LocalizationInitial()) {
    on<InitializeLocalizationEvent>(_onInitialize);
    on<LoadTranslationsEvent>(_onLoadTranslations);
    on<ChangeLocaleEvent>(_onChangeLocale);
    on<RefreshTranslationsEvent>(_onRefreshTranslations);
  }

  Future<void> _onInitialize(
    InitializeLocalizationEvent event,
    Emitter<LocalizationState> emit,
  ) async {
    emit(const LocalizationLoading(message: 'Initializing...'));

    final savedLocale = await _repository.getPreferredLocale();
    final localeToLoad = savedLocale ?? 'en';

    final result = await _getTranslationsUseCase(
      GetTranslationsParams(locale: localeToLoad),
    );

    result.fold(
      (failure) {
        emit(LocalizationError(
          message: failure.message,
          code: failure.code,
        ));
      },
      (translations) {
        AppStrings.updateTranslations(
          translations.translations.map((k, v) => MapEntry(k, v.toString())),
          translations.locale,
        );

        emit(LocalizationLoaded(
          locale: Locale(localeToLoad),
          translations: translations,
        ));
      },
    );
  }

  Future<void> _onChangeLocale(
    ChangeLocaleEvent event,
    Emitter<LocalizationState> emit,
  ) async {
    final currentState = state;
    final currentLocale = currentState is LocalizationLoaded
        ? currentState.locale
        : const Locale('en');

    if (currentLocale.languageCode == event.locale) return;

    emit(LocaleChanging(
      fromLocale: currentLocale,
      toLocale: Locale(event.locale),
    ));

    final result = await _changeLocaleUseCase(
      ChangeLocaleParams(locale: event.locale),
    );

    result.fold(
      (failure) {
        if (currentState is LocalizationLoaded) {
          emit(currentState);
        }
        emit(LocalizationError(
          message: failure.message,
          code: failure.code,
          fallbackLocale: currentLocale,
        ));
      },
      (translations) {
        AppStrings.updateTranslations(
          translations.translations.map((k, v) => MapEntry(k, v.toString())),
          translations.locale,
        );

        final supportedLocales = currentState is LocalizationLoaded
            ? currentState.supportedLocales
            : const ['en', 'es', 'fr', 'de', 'hi', 'ar'];

        emit(LocalizationLoaded(
          locale: Locale(event.locale),
          translations: translations,
          supportedLocales: supportedLocales,
        ));
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Dependency Injection

The injection container wires everything together:

/// lib/core/di/injection_container.dart

final GetIt sl = GetIt.instance;

Future<void> initDependencies() async {
  await Hive.initFlutter();

  final sharedPreferences = await SharedPreferences.getInstance();
  sl.registerLazySingleton<SharedPreferences>(() => sharedPreferences);

  sl.registerLazySingleton<Connectivity>(() => Connectivity());

  sl.registerLazySingleton<Dio>(() => _createDio());

  sl.registerLazySingleton<NetworkInfo>(
    () => NetworkInfoImpl(sl<Connectivity>()),
  );

  sl.registerLazySingleton<LocalizationRemoteDataSource>(
    () => LocalizationRemoteDataSourceImpl(sl<Dio>()),
  );

  sl.registerLazySingleton<LocalizationLocalDataSource>(
    () => LocalizationLocalDataSourceImpl(sl<SharedPreferences>()),
  );

  sl.registerLazySingleton<LocalizationRepository>(
    () => LocalizationRepositoryImpl(
      remoteDataSource: sl<LocalizationRemoteDataSource>(),
      localDataSource: sl<LocalizationLocalDataSource>(),
      networkInfo: sl<NetworkInfo>(),
    ),
  );

  sl.registerLazySingleton<GetTranslationsUseCase>(
    () => GetTranslationsUseCase(sl<LocalizationRepository>()),
  );

  sl.registerLazySingleton<ChangeLocaleUseCase>(
    () => ChangeLocaleUseCase(sl<LocalizationRepository>()),
  );

  sl.registerLazySingleton<GetSupportedLocalesUseCase>(
    () => GetSupportedLocalesUseCase(sl<LocalizationRepository>()),
  );

  sl.registerLazySingleton<LocalizationBloc>(
    () => LocalizationBloc(
      getTranslationsUseCase: sl<GetTranslationsUseCase>(),
      changeLocaleUseCase: sl<ChangeLocaleUseCase>(),
      getSupportedLocalesUseCase: sl<GetSupportedLocalesUseCase>(),
      repository: sl<LocalizationRepository>(),
    ),
  );
}

Dio _createDio() {
  final dio = Dio(
    BaseOptions(
      baseUrl: ApiConfig.baseUrl,
      connectTimeout: ApiConfig.connectTimeout,
      receiveTimeout: ApiConfig.receiveTimeout,
      sendTimeout: ApiConfig.sendTimeout,
      headers: ApiConfig.defaultHeaders,
    ),
  );

  dio.interceptors.add(
    LogInterceptor(
      request: true,
      requestHeader: true,
      requestBody: true,
      responseHeader: true,
      responseBody: true,
      error: true,
    ),
  );

  return dio;
}
Enter fullscreen mode Exit fullscreen mode

Backend Setup

The backend runs on Node.js with Express and MongoDB Atlas. Here's how to set it up:

  1. Create a MongoDB Atlas account at mongodb.com and create a free cluster
  2. Get your connection string from the Atlas dashboard
  3. Create the backend project with Express and Mongoose

The API exposes three endpoints:

  • GET /api/v1/translations/:locale - Returns translations for a specific locale
  • GET /api/v1/translations/supported-locales - Returns list of available locales
  • GET /api/v1/translations/:locale?current_version=X - Conditional request that returns 304 if version matches

The translation documents in MongoDB follow this schema:

{
  locale: "en",
  version: "1.0.0",
  updated_at: ISODate("2024-01-15T10:30:00Z"),
  translations: {
    "app_title": "Localization Demo",
    "welcome_message": "Welcome, {name}!",
    "login": "Login",
    // ... more keys
  }
}
Enter fullscreen mode Exit fullscreen mode

When the client sends current_version as a query parameter, the server compares it with the database version. If they match, it returns HTTP 304 with no body. Otherwise, it returns the full translation document with HTTP 200.


Running the App

Initialize dependencies in main.dart:

/// lib/main.dart

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await initDependencies();
  runApp(const LocalizationApp());
}

class LocalizationApp extends StatelessWidget {
  const LocalizationApp({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider<LocalizationBloc>(
      create: (_) => sl<LocalizationBloc>()
        ..add(const InitializeLocalizationEvent()),
      child: BlocBuilder<LocalizationBloc, LocalizationState>(
        buildWhen: (previous, current) {
          if (previous is LocalizationLoaded && current is LocalizationLoaded) {
            return previous.locale != current.locale;
          }
          return true;
        },
        builder: (context, state) {
          Locale currentLocale = const Locale('en', 'US');
          if (state is LocalizationLoaded) {
            currentLocale = state.locale;
          }

          final isRtl = SupportedLocales.findByCode(
            currentLocale.languageCode,
          ).isRtl;

          return MaterialApp(
            title: AppStrings.getValue(AppStrings.appTitle),
            debugShowCheckedModeBanner: false,
            theme: AppTheme.light,
            locale: currentLocale,
            supportedLocales: SupportedLocales.flutterLocales,
            localizationsDelegates: const [
              GlobalMaterialLocalizations.delegate,
              GlobalWidgetsLocalizations.delegate,
              GlobalCupertinoLocalizations.delegate,
            ],
            home: Directionality(
              textDirection: isRtl ? TextDirection.rtl : TextDirection.ltr,
              child: const HomePage(),
            ),
          );
        },
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Common Issues and Solutions

Server connection refused on Android emulator:
Use 10.0.2.2 instead of localhost in your base URL. The emulator's localhost refers to itself, not your development machine.

HTTP 304 treated as an error:
Configure Dio's validateStatus to accept 304: validateStatus: (status) => status != null && (status < 400 || status == 304)

Translations not updating:
Check the version number in your MongoDB document. The server only returns 304 if versions match. Increment the version to force a refresh.

Cleartext traffic blocked:
Add android:usesCleartextTraffic="true" to your AndroidManifest.xml during development. Remove it before production.

MongoDB Atlas connection issues:
Ensure your IP address is whitelisted in Atlas Network Access settings. The error "ECONNREFUSED" usually means network restrictions.


How It All Connects: The Complete Flow

Understanding how data flows through the application helps you debug issues and extend the system. Here's the complete journey of a translation request:

┌─────────────────────────────────────────────────────────────────────────────┐
│                            APPLICATION STARTUP                               │
└─────────────────────────────────────────────────────────────────────────────┘
                                      │
                                      ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  1. main.dart initializes dependencies via GetIt                            │
│     └── Registers: Dio, SharedPreferences, Hive, BLoC, UseCases            │
└─────────────────────────────────────────────────────────────────────────────┘
                                      │
                                      ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  2. LocalizationBloc receives InitializeLocalizationEvent                   │
│     └── Checks SharedPreferences for saved locale preference                │
└─────────────────────────────────────────────────────────────────────────────┘
                                      │
                                      ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  3. GetTranslationsUseCase calls LocalizationRepository                     │
│     └── Repository checks network connectivity                              │
└─────────────────────────────────────────────────────────────────────────────┘
                                      │
                    ┌─────────────────┴─────────────────┐
                    │                                   │
              [ONLINE]                             [OFFLINE]
                    │                                   │
                    ▼                                   ▼
┌───────────────────────────────┐     ┌───────────────────────────────────────┐
│  4a. RemoteDataSource calls   │     │  4b. LocalDataSource reads from       │
│      backend API via Dio      │     │      Hive cache                       │
│      GET /translations/{locale}│     │                                       │
│      ?current_version=X       │     │                                       │
└───────────────────────────────┘     └───────────────────────────────────────┘
                    │                                   │
         ┌─────────┴─────────┐                         │
         │                   │                         │
    [HTTP 200]          [HTTP 304]                     │
         │                   │                         │
         ▼                   ▼                         │
┌─────────────────┐  ┌─────────────────┐              │
│ New translations│  │ CacheValidExcep │              │
│ returned        │  │ tion thrown     │              │
└─────────────────┘  └─────────────────┘              │
         │                   │                         │
         ▼                   └────────────┬────────────┘
┌─────────────────┐                       │
│ Cache new data  │                       │
│ in Hive         │                       │
└─────────────────┘                       │
         │                                │
         └───────────────┬────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  5. Repository returns Either<Failure, TranslationEntity>                   │
│     └── BLoC emits LocalizationLoaded state with translations              │
└─────────────────────────────────────────────────────────────────────────────┘
                                      │
                                      ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│  6. AppStrings.updateTranslations() stores translations in memory           │
│     └── UI rebuilds with BlocBuilder, displaying localized content         │
└─────────────────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Language Change Flow

When a user selects a new language:

User taps language    ──►  ChangeLocaleEvent dispatched
        │
        ▼
BLoC emits LocaleChanging state (shows loading indicator)
        │
        ▼
ChangeLocaleUseCase fetches new translations
        │
        ▼
On success: Save preference + Cache translations + Emit LocalizationLoaded
        │
        ▼
MaterialApp rebuilds with new locale
        │
        ▼
Directionality widget updates for RTL languages (Arabic)
Enter fullscreen mode Exit fullscreen mode

Cache Validation Strategy

The HTTP 304 mechanism saves bandwidth and improves performance:

First Request (no cache):
  Client: GET /translations/en
  Server: 200 OK + Full translation data (version: 1.0.0)
  Client: Stores in Hive with version

Subsequent Request (with cache):
  Client: GET /translations/en?current_version=1.0.0
  Server: Compares versions
    └── Same version  → 304 Not Modified (no body)
    └── New version   → 200 OK + Updated translations

Result: Only downloads data when translations actually change
Enter fullscreen mode Exit fullscreen mode

Performance Optimization Tips

Building on this foundation, here are strategies to maximize performance:

Lazy Loading Translations

Instead of loading all translations at startup, consider loading only the current locale and prefetching others in the background:

// In your BLoC, after loading primary locale
Future<void> _prefetchOtherLocales() async {
  final otherLocales = ['es', 'fr', 'de'].where((l) => l != currentLocale);
  for (final locale in otherLocales) {
    await _repository.getTranslations(locale); // Caches silently
  }
}
Enter fullscreen mode Exit fullscreen mode

Memory-Efficient String Access

The AppStrings.getValue() method avoids creating new string objects for cached values:

// Efficient: Retrieves from in-memory map
Text(AppStrings.getValue(AppStrings.login))

// Less efficient: Creates interpolated string each time
Text('${translations['login']}')
Enter fullscreen mode Exit fullscreen mode

Minimize Rebuilds

Use buildWhen in BlocBuilder to prevent unnecessary widget rebuilds:

BlocBuilder<LocalizationBloc, LocalizationState>(
  buildWhen: (previous, current) {
    // Only rebuild when locale actually changes
    if (previous is LocalizationLoaded && current is LocalizationLoaded) {
      return previous.locale != current.locale;
    }
    return true;
  },
  builder: (context, state) => /* your widget */,
)
Enter fullscreen mode Exit fullscreen mode

Testing Your Implementation

A robust localization system needs thorough testing. Here are the key test scenarios:

Unit Tests for Repository

test('should return cached translations when server returns 304', () async {
  // Arrange
  when(mockRemoteDataSource.getTranslations('en', currentVersion: '1.0.0'))
      .thenThrow(const CacheValidException());
  when(mockLocalDataSource.getCachedTranslations('en'))
      .thenAnswer((_) async => tTranslationModel);

  // Act
  final result = await repository.getTranslations('en');

  // Assert
  expect(result, Right(tTranslationModel));
  verify(mockLocalDataSource.getCachedTranslations('en'));
});
Enter fullscreen mode Exit fullscreen mode

Integration Test for Language Switching

testWidgets('should switch language and update UI', (tester) async {
  await tester.pumpWidget(const LocalizationApp());
  await tester.pumpAndSettle();

  // Verify initial English text
  expect(find.text('Login'), findsOneWidget);

  // Navigate to settings and change language
  await tester.tap(find.byIcon(Icons.settings));
  await tester.pumpAndSettle();
  await tester.tap(find.text('Español'));
  await tester.pumpAndSettle();

  // Verify Spanish text appears
  expect(find.text('Iniciar sesión'), findsOneWidget);
});
Enter fullscreen mode Exit fullscreen mode

Offline Mode Test

test('should use cached translations when offline', () async {
  // Arrange
  when(mockNetworkInfo.isConnected).thenAnswer((_) async => false);
  when(mockLocalDataSource.getCachedTranslations('en'))
      .thenAnswer((_) async => tTranslationModel);

  // Act
  final result = await repository.getTranslations('en');

  // Assert
  expect(result.isRight(), true);
  verifyNever(mockRemoteDataSource.getTranslations(any));
});
Enter fullscreen mode Exit fullscreen mode

Security Considerations

When implementing backend-driven localization in production:

API Security

  • Use HTTPS in production (remove usesCleartextTraffic from AndroidManifest)
  • Implement rate limiting on translation endpoints to prevent abuse
  • Consider adding API key authentication for translation requests

Content Validation

  • Sanitize translation strings on the backend before storing
  • Validate that translation keys match expected patterns
  • Implement content moderation if translations are user-generated

Cache Security

  • Hive encrypts data at rest when configured properly
  • Consider encrypting sensitive translation keys
  • Clear cached translations on user logout if they contain personalized content

Conclusion

Building a backend-driven localization system requires thoughtful architecture, but the benefits far outweigh the initial complexity. You now have a system where translations live independently from your app binary, updates happen instantly without app store delays, and users always see the most current content regardless of their app version.

The Clean Architecture approach pays dividends as your app grows. Need to switch from Hive to SQLite for caching? Change only the local data source. Want to add WebSocket support for real-time updates? Modify the remote data source and repository. The domain layer with its use cases and entities remains untouched, protecting your business logic from infrastructure changes.

The HTTP 304 mechanism might seem like a small optimization, but at scale it significantly reduces bandwidth costs and improves app responsiveness. Users with slow connections appreciate faster language switches when translations are already cached.

Consider this implementation a starting point. The patterns established here extend naturally to other dynamic content: feature flags, remote configuration, A/B testing variants, and more. Once you have the infrastructure for fetching, caching, and displaying dynamic content, applying it to new use cases becomes straightforward.

The combination of offline-first caching, intelligent cache validation, and robust error handling creates an experience that feels native while providing the flexibility of server-driven content. Users get instant language switching with cached data, automatic updates when new translations are available, and graceful fallbacks when network conditions are poor.


Source Code

The complete implementation is available on GitHub. Clone the repository, configure your backend URL, and you'll have a working localization system ready for customization.

GitHub Repository: https://github.com/Anurag-Dubey12/backend-based-Localization/tree/main

The repository includes:

  • Complete Flutter application with all layers implemented
  • Node.js backend with Express and MongoDB integration
  • Sample translation data for all six supported languages

Top comments (0)