Skip to content
Back to archipelago.banuacoder.com

Architecture Deep Dives

Understanding API/Impl Split ​

What you'll learn ​

  • When to use the API/Impl split pattern
  • How the split enforces clean architecture boundaries
  • The relationship between API, Impl, and Noop packages
  • Real-world examples from the Archipelago template

The Problem ​

In a modular monorepo, features inevitably depend on each other. Without boundaries, you end up with:

  • Circular dependencies between feature packages
  • Tight coupling to specific implementations
  • Inability to swap vendors without touching consumers
  • Test setups that require spinning up real services

The Solution: API/Impl Split ​

The API/Impl split separates what a feature does from how it does it:

text
feature_x/
├── feature_x_api/    # Contracts: interfaces, models, exceptions
└── feature_x_impl/   # Implementation: datasources, repos, DI

Other features depend only on feature_x_api. They never import feature_x_impl directly.

Here is that rule on real code. The Flagr feature-flag provider depends on the HttpClient contract in network_sdk_api. Switching from Dio to package:http changes the app's wiring only:

API/Impl splitSwap the HTTP vendor without touching feature code

Step 1 of 2, Dio: Code that needs HTTP, like the Flagr feature-flag provider, depends only on the HttpClient contract in network_sdk_api. The app picks Dio in its pubspec and its injector.

infrastructure/flagr_feature_flag_impl/lib/src/flagr_feature_flag_provider.dart
apps/my_app/pubspec.yaml
dependencies:  network_sdk_api:    path: ../../infrastructure/network_sdk_api  # …  network_sdk_dio:    path: ../../infrastructure/network_sdk_dio
apps/my_app/lib/di/injector.dart
import 'package:network_sdk_dio/network_sdk_dio.dart';// …  externalPackageModulesBefore: [    ExternalModule(NetworkSdkDioPackageModule),
infrastructure/network_sdk_dio/lib/src/client/dio_http_client_adapter.dart
class DioHttpClientAdapter(this._dio) implements api.HttpClient {  @override  Future<api.HttpResponse> post(String path, {Object? body, /* … */}) =>      _send('POST', path, body: body, /* … */);}

When to Split ​

Not every feature needs a split. Use this decision tree:

Split when: ​

  • Other features depend on this feature's contracts
  • You want to swap the underlying vendor (e.g., Firebase to Supabase)
  • The feature has a noop variant for build-time stripping

Don't split when: ​

  • The feature is self-contained (no external consumers)
  • It's a simple utility package
  • It's a debug-only tool (use standalone noop pattern instead)

API Package Anatomy ​

The API package contains only contracts:

dart
// feature_x_api/lib/src/repositories/feature_x_repository.dart
abstract class FeatureXRepository {
  Future<List<Item>> getItems();
  Future<void> createItem(CreateItemRequest request);
}
dart
// feature_x_api/lib/src/models/item.dart
class Item {
  final String id;
  final String name;
  const Item({required this.id, required this.name});
}

No implementation details. No third-party dependencies (except shared models).

Impl Package Anatomy ​

The impl package provides the concrete implementation:

dart
// feature_x_impl/lib/src/repositories/feature_x_repository_impl.dart
class FeatureXRepositoryImpl implements FeatureXRepository {
  final FeatureXRemoteDataSource _remote;

  const FeatureXRepositoryImpl(this._remote);

  @override
  Future<List<Item>> getItems() => _remote.fetchItems();
}

The impl package depends on the API package and any vendor SDKs it needs.

Connecting with DI ​

The global GetIt wires everything together at the app level. Consumers request contracts from GetIt, and the DI configuration decides which implementation to provide.

Learn more about this in Dual-GetIt DI Pattern.

Next Steps ​