DEV Community

Kelvin Wieth
Kelvin Wieth

Posted on

Rethinking interfaces in Flutter projects

Let's say we have this code:

import 'package:flutter/foundation.dart';

abstract interface class NameRepository {
  Future<List<String>> getAll();
}

class NameRepositoryImpl implements NameRepository {
  NameRepositoryImpl({required dynamic httpClient}) : _httpClient = httpClient;

  final dynamic _httpClient;

  @override
  Future<List<String>> getAll() async => _httpClient
      .get('v1/names')
      .body()
      .maybeAs<List<String>>()
      .orElse(() => []);
}

class NameController extends ChangeNotifier {
  NameController({required NameRepository repository})
      : _repository = repository;

  final NameRepository _repository;

  bool _isLoading = false;
  bool get isLoading => _isLoading;

  List<String> _names = [];
  List<String> get names => _names;

  Future<void> init() async {
    _isLoading = true;
    notifyListeners();

    _names = await _repository.getAll();
    _isLoading = false;
    notifyListeners();
  }
}
Enter fullscreen mode Exit fullscreen mode

Pretty common, hum? At first, we all think interfaces are good to write tests, like this:

import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

void main() {
  late MockNameRepository mockNameRepository;

  setUp(() {
    mockNameRepository = MockNameRepository();
  });

  test('init calls repository and sets correct properties', () async {
    // Arrange
    final sut = NameController(repository: mockNameRepository);
    when(() => mockNameRepository.getAll()).thenAnswer((_) async => ['foo']);

    // Act
    await sut.init();

    // Assert
    verify(() => mockNameRepository.getAll()).called(1);
    expect(sut.names, equals(['foo']));
    expect(sut.isLoading, isFalse);
  });
}
Enter fullscreen mode Exit fullscreen mode

But the question is: since Dart allows us to implement and extend regular classes, not only abstract ones, why do we keep creating interfaces for simple cases like this? Instead, we could simply do:

class NameRepository {
  NameRepository({required dynamic httpClient}) : _httpClient = httpClient;

  final dynamic _httpClient;

  Future<List<String>> getAll() async => _httpClient
      .get('v1/names')
      .body()
      .maybeAs<List<String>>()
      .orElse(() => []);
}
Enter fullscreen mode Exit fullscreen mode

And incredibly we don't have to touch our tests to fix.

Of course there are still cases where interfaces are good and the best choice, specially when you build native plugins or something that needs more than one implementation - for instance, switching from online to a local database depending on device connection.

However, since most of the time we only use interfaces for test purposes, this approach has at least 2 benefits:

  • Eliminates boilerplate code;
  • Makes the debug proccess less painful, as we don't have to find for implementations of our interface; pressing F12 will lead us directly to the code.

This idea came from this thread on Bloc Discord server.

Top comments (0)