Interfaces #
An interface is the mechanism for defining a contract — an agreement about what an object can do, regardless of how it does it. Dart doesn’t have a separate interface keyword like Java or C#, but its approach is actually more flexible: every class automatically defines an implicit interface consisting of all its public members, and an abstract class can serve as an interface with the extra capability of concrete methods and richer documentation. Understanding interfaces isn’t just about the implements syntax — it’s about how to design the boundaries between components so code can be tested, extended, and have its implementations swapped without major changes.
Why Interfaces? #
Imagine a feature that fetches user data from an API and displays it. If the presentation code directly calls an HTTP client, it’s tightly coupled to network implementation details. When you want to test the presentation logic, you have to set up a real server. When the company moves from REST to GraphQL, all the presentation code must change.
Interfaces break this dependency:
// ANTI-PATTERN: direct dependency on an implementation
class HalamanProfil {
final HttpClient _client = HttpClient(); // ✗ coupled to HttpClient
Future<void> muat(String id) async {
final response = await _client.get('/api/users/$id');
// ... process the response
}
}
// CORRECT: depend on an abstraction, not an implementation
abstract class PenggunaSumber {
Future<Pengguna> ambil(String id);
Future<List<Pengguna>> ambilSemua();
}
class HalamanProfil {
final PenggunaSumber _sumber; // depends on the contract, not the details
HalamanProfil(this._sumber); // the implementation is injected from outside
Future<void> muat(String id) async {
final pengguna = await _sumber.ambil(id);
// ... display the user
}
}
With this pattern, HalamanProfil can be tested with a fake implementation (mock), and the real implementation can be swapped without touching the presentation code at all.
Implicit Interfaces — Every Class Is an Interface #
In Dart, every class automatically defines an implicit interface — the set of all public members that can be implementsed by other classes. No special declaration needed.
// A regular class — also defines an implicit interface
class Printer {
void cetak(String teks) => print(teks);
void cetakBold(String teks) => print('**$teks**');
}
// This class implements Printer's implicit interface
// MUST implement ALL of Printer's public members
class PrinterPDF implements Printer {
@override
void cetak(String teks) {
// write to a PDF file
print('[PDF] $teks');
}
@override
void cetakBold(String teks) {
print('[PDF BOLD] $teks');
}
}
The crucial difference between extends and implements:
class A {
void hello() => print('Hello from A');
void salam() => print('Salam from A');
}
// extends — inherits implementations
class B extends A {
@override
void hello() => print('Hello from B');
// salam() isn't needed — already inherited from A
}
// implements — MUST implement everything from scratch
class C implements A {
@override
void hello() => print('Hello from C'); // required
@override
void salam() => print('Salam from C'); // required — no inheritance
}
Using a concrete class as an interface (implements KelasKonkret) has a risk: every time a new public member is added to that class, all implementors must add a new override. Use anabstract classorinterface class(Dart 3) as an explicit contract to avoid this.
abstract class as an Interface
#
abstract class is the most common way to define interfaces in Dart. It can contain abstract methods (mandatory contract), concrete methods (default implementations), and abstract getters — providing flexibility that the interface keyword in other languages doesn’t have.
// An interface for a user data source
abstract class PenggunaSumber {
// Mandatory contract — must be implemented
Future<Pengguna?> ambilById(String id);
Future<List<Pengguna>> ambilSemua({int halaman = 1, int perHalaman = 20});
Future<Pengguna> simpan(Pengguna pengguna);
Future<void> hapus(String id);
// Concrete methods — default implementations that can be overridden
Future<bool> ada(String id) async {
return await ambilById(id) != null;
}
Future<int> hitungTotal() async {
final semua = await ambilSemua(perHalaman: 1);
return semua.length; // naive implementation — can be overridden
}
}
// Real implementation — from a database
class PenggunaDatabaseSumber extends PenggunaSumber {
final Database _db;
PenggunaDatabaseSumber(this._db);
@override
Future<Pengguna?> ambilById(String id) async {
final rows = await _db.query('pengguna', where: 'id = ?', whereArgs: [id]);
return rows.isEmpty ? null : Pengguna.dariMap(rows.first);
}
@override
Future<List<Pengguna>> ambilSemua({int halaman = 1, int perHalaman = 20}) async {
final offset = (halaman - 1) * perHalaman;
final rows = await _db.query('pengguna', limit: perHalaman, offset: offset);
return rows.map(Pengguna.dariMap).toList();
}
@override
Future<Pengguna> simpan(Pengguna pengguna) async {
await _db.insert('pengguna', pengguna.keMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
return pengguna;
}
@override
Future<void> hapus(String id) =>
_db.delete('pengguna', where: 'id = ?', whereArgs: [id]);
// Override the default implementation with a more efficient version
@override
Future<int> hitungTotal() async {
final result = await _db.rawQuery('SELECT COUNT(*) FROM pengguna');
return Sqflite.firstIntValue(result) ?? 0;
}
}
// Implementation for testing — never touches the database at all
class PenggunaSumberFake extends PenggunaSumber {
final Map<String, Pengguna> _store = {};
@override
Future<Pengguna?> ambilById(String id) async => _store[id];
@override
Future<List<Pengguna>> ambilSemua({int halaman = 1, int perHalaman = 20}) async {
return _store.values.toList();
}
@override
Future<Pengguna> simpan(Pengguna pengguna) async {
_store[pengguna.id] = pengguna;
return pengguna;
}
@override
Future<void> hapus(String id) async => _store.remove(id);
}
interface class — Dart 3
#
Dart 3 introduced new class modifiers that give more explicit control over how a class is used:
// interface class — can only be implemented, not extended
interface class Serializable {
Map<String, dynamic> keJson();
String keJsonString() => jsonEncode(keJson());
}
// abstract interface class — a combination: can't be instantiated,
// and can only be implemented (not extended)
abstract interface class Repository<T, ID> {
Future<T?> cariById(ID id);
Future<List<T>> cariSemua();
Future<T> simpan(T entitas);
Future<void> hapus(ID id);
}
| Modifier | Can be instantiated | Can extends | Can implements | Can with |
|---|---|---|---|---|
| (no modifier) | ✓ | ✓ | ✓ | ✗ |
abstract | ✗ | ✓ | ✓ | ✗ |
interface | ✓ | ✗ | ✓ | ✗ |
abstract interface | ✗ | ✗ | ✓ | ✗ |
base | ✓ | ✓ | ✗ | ✗ |
final | ✓ | ✗ | ✗ | ✗ |
sealed | ✗ | ✗ subtype in the same file | ✗ | ✗ |
Implementing Multiple Interfaces #
One of the advantages of implements over extends is the ability to implement several interfaces at once — overcoming Dart’s single inheritance limitation:
abstract class DapatDisimpan {
Future<void> simpanKeDisk(String path);
Future<void> muatDariDisk(String path);
}
abstract class DapatDiekspor {
List<int> keBytes();
String keCsv();
}
abstract class DapatDivalidasi {
bool validasi();
List<String> pesanValidasi();
}
// Dokumen implements three interfaces at once
class DokumenLaporan
implements DapatDisimpan, DapatDiekspor, DapatDivalidasi {
final String judul;
final List<Map<String, dynamic>> baris;
DokumenLaporan({required this.judul, required this.baris});
@override
Future<void> simpanKeDisk(String path) async {
final file = File(path);
await file.writeAsString(jsonEncode({'judul': judul, 'baris': baris}));
}
@override
Future<void> muatDariDisk(String path) async { /* ... */ }
@override
List<int> keBytes() => utf8.encode(keCsv());
@override
String keCsv() {
// convert rows to CSV format
return baris.map((b) => b.values.join(',')).join('\n');
}
@override
bool validasi() => judul.isNotEmpty && baris.isNotEmpty;
@override
List<String> pesanValidasi() {
final pesan = <String>[];
if (judul.isEmpty) pesan.add('Title cannot be empty');
if (baris.isEmpty) pesan.add('Report cannot be empty');
return pesan;
}
}
The Interface Segregation Principle #
One of the SOLID principles most relevant to interfaces: the Interface Segregation Principle (ISP) — clients must not be forced to depend on methods they don’t use. Interfaces that are too large should be split into smaller, focused interfaces.
// ANTI-PATTERN: a monolithic interface forcing implementation of irrelevant methods
abstract class PenggunaLayanan {
Future<Pengguna?> ambilById(String id);
Future<void> simpan(Pengguna p);
Future<void> hapus(String id);
Future<void> kirimEmail(String id, String subjek, String isi);
Future<void> kirimSms(String id, String pesan);
Future<void> pushNotifikasi(String id, String judul);
Future<void> ekspor(String format, String path);
Future<void> impor(String path);
Future<Map<String, int>> statistik();
}
// ✗ A component that only needs to read data is forced to depend on
// kirimEmail, hapus, ekspor methods it never uses
// CORRECT: small, focused interfaces
abstract class PenggunaPembaca {
Future<Pengguna?> ambilById(String id);
Future<List<Pengguna>> cari({String? kata, int? halaman});
}
abstract class PenggunaPenulis {
Future<Pengguna> simpan(Pengguna p);
Future<void> hapus(String id);
}
abstract class PenggunaNotifikasi {
Future<void> kirimEmail(String id, String subjek, String isi);
Future<void> kirimSms(String id, String pesan);
Future<void> pushNotifikasi(String id, String judul);
}
abstract class PenggunaEkspor {
Future<void> ekspor(String format, String path);
Future<void> impor(String path);
}
// Implementations can combine as needed
class PenggunaSqlLayanan
implements PenggunaPembaca, PenggunaPenulis, PenggunaEkspor {
// only the relevant implementations
}
// A component that only needs reading depends only on PenggunaPembaca
class HalamanPencarian {
final PenggunaPembaca _pembaca; // clean — only needs this
HalamanPencarian(this._pembaca);
}
flowchart LR
subgraph Before ISP
M["PenggunaLayanan\n(monolithic)"] --> A
M --> B
M --> C
end
subgraph After ISP
P["PenggunaPembaca"] --> A["HalamanPencarian"]
W["PenggunaPenulis"] --> B["FormEdit"]
N["PenggunaNotifikasi"] --> C["LayananEmail"]
endThe Dependency Inversion Principle with Interfaces #
The Dependency Inversion Principle (DIP) states: high-level modules must not depend on low-level modules — both must depend on abstractions. Interfaces are the main mechanism for applying this principle in Dart.
// Without DIP — the high-level module depends directly on details
class LayananOrder {
final MysqlDatabase _db = MysqlDatabase(); // ✗ coupled to MySQL
final SendgridEmail _email = SendgridEmail(); // ✗ coupled to Sendgrid
Future<void> buatOrder(Order order) async {
await _db.simpan(order);
await _email.kirim(order.emailPelanggan, 'Order dibuat');
}
}
// Switch to PostgreSQL? Switch email to SES? Must modify LayananOrder.
// With DIP — depend on abstractions
abstract class OrderRepository {
Future<void> simpan(Order order);
Future<Order?> ambil(String id);
}
abstract class EmailService {
Future<void> kirim(String kepada, String subjek, String isi);
}
class LayananOrder {
final OrderRepository _repo; // abstraction
final EmailService _email; // abstraction
LayananOrder({required OrderRepository repo, required EmailService email})
: _repo = repo, _email = email;
Future<void> buatOrder(Order order) async {
await _repo.simpan(order);
await _email.kirim(
order.emailPelanggan,
'Order #${order.id} created successfully',
'Thank you for shopping!',
);
}
}
// Concrete implementations — swappable without touching LayananOrder
class MysqlOrderRepository implements OrderRepository { /* ... */ }
class PostgresOrderRepository implements OrderRepository { /* ... */ }
class SendgridEmailService implements EmailService { /* ... */ }
class AwsSesEmailService implements EmailService { /* ... */ }
// Assembly at the application level (composition root)
final layanan = LayananOrder(
repo: PostgresOrderRepository(connectionString),
email: AwsSesEmailService(apiKey),
);
Interfaces for Testing with Mocks #
One of the biggest benefits of interfaces is testability — you can create fake implementations (mock/fake/stub) that behave per the test scenario without touching real infrastructure.
// The interface under test
abstract class PembayaranGateway {
Future<HasilPembayaran> proses(Pembayaran pembayaran);
Future<bool> verifikasi(String idTransaksi);
Future<void> refund(String idTransaksi, double jumlah);
}
// Fake implementation for testing
class PembayaranGatewayFake implements PembayaranGateway {
final List<Pembayaran> pembayaranDisimpan = [];
bool _simulasiGagal = false;
void simulasiKegagalan() => _simulasiGagal = true;
@override
Future<HasilPembayaran> proses(Pembayaran pembayaran) async {
if (_simulasiGagal) {
return HasilPembayaran.gagal(pesan: 'Simulated failure');
}
pembayaranDisimpan.add(pembayaran);
return HasilPembayaran.sukses(idTransaksi: 'FAKE-${pembayaran.id}');
}
@override
Future<bool> verifikasi(String idTransaksi) async => true;
@override
Future<void> refund(String idTransaksi, double jumlah) async {
pembayaranDisimpan.removeWhere((p) => 'FAKE-${p.id}' == idTransaksi);
}
}
// Test using the fake
void main() {
group('LayananPembayaran', () {
late PembayaranGatewayFake fakeGateway;
late LayananPembayaran layanan;
setUp(() {
fakeGateway = PembayaranGatewayFake();
layanan = LayananPembayaran(gateway: fakeGateway);
});
test('successfully processes a valid payment', () async {
final pembayaran = Pembayaran(id: 'P001', jumlah: 100000);
final hasil = await layanan.prosesPembayaran(pembayaran);
expect(hasil.sukses, isTrue);
expect(fakeGateway.pembayaranDisimpan, contains(pembayaran));
});
test('handles gateway failure correctly', () async {
fakeGateway.simulasiKegagalan();
final hasil = await layanan.prosesPembayaran(Pembayaran(jumlah: 100000));
expect(hasil.sukses, isFalse);
expect(hasil.pesan, isNotEmpty);
});
});
}
Polymorphism through Interfaces #
Interfaces let code work with various different implementations through the same type — this is polymorphism. Code that depends on an interface doesn’t need to know which concrete implementation is currently in use.
abstract class PengeksplorData {
Stream<Map<String, dynamic>> baca();
Future<void> tutup();
}
class CsvEksplorer implements PengeksplorData {
final String path;
CsvEksplorer(this.path);
@override
Stream<Map<String, dynamic>> baca() async* {
final baris = await File(path).readAsLines();
final header = baris.first.split(',');
for (final b in baris.skip(1)) {
final nilai = b.split(',');
yield Map.fromIterables(header, nilai);
}
}
@override
Future<void> tutup() async {} // files don't need explicit closing
}
class DatabaseEksplorer implements PengeksplorData {
final Database db;
final String tabel;
DatabaseEksplorer(this.db, this.tabel);
@override
Stream<Map<String, dynamic>> baca() async* {
final rows = await db.query(tabel);
for (final row in rows) yield row;
}
@override
Future<void> tutup() => db.close();
}
class ApiEksplorer implements PengeksplorData {
final String url;
ApiEksplorer(this.url);
@override
Stream<Map<String, dynamic>> baca() async* {
int halaman = 1;
while (true) {
final response = await http.get(Uri.parse('$url?page=$halaman'));
final data = jsonDecode(response.body) as List;
if (data.isEmpty) break;
for (final item in data) yield item as Map<String, dynamic>;
halaman++;
}
}
@override
Future<void> tutup() async {}
}
// A processor that works with all implementations — regardless of the source
class ProsesorData {
Future<void> proses(PengeksplorData sumber) async {
int hitungBaris = 0;
await for (final baris in sumber.baca()) {
// process each row
hitungBaris++;
}
await sumber.tutup();
print('Processed: $hitungBaris rows');
}
}
// Usage — the source can be swapped without changing ProsesorData
final prosesor = ProsesorData();
await prosesor.proses(CsvEksplorer('data.csv'));
await prosesor.proses(DatabaseEksplorer(db, 'pengguna'));
await prosesor.proses(ApiEksplorer('https://api.example.com/data'));
When to Use abstract class vs a Regular Class as an Interface
#
Choosing the right interface form depends on several factors:
USE an abstract class when:
✓ The interface needs to provide default implementations for some methods
✓ There's state or properties that can be shared between implementations
✓ You want to document the contract with rich DartDoc
✓ Implementors will most likely use extends rather than implements
✓ The interface is part of a deeper class hierarchy
USE an interface class (Dart 3) when:
✓ You want to explicitly prevent extends (only implements allowed)
✓ The interface is a pure contract without default implementations
✓ Defining the boundary between a public library and internal implementations
USE a regular class as an interface when:
✓ The class already exists and you want to use it as a contract opportunistically
✓ You don't care whether implementors use extends or implements
AVOID using a concrete class as an interface if:
✗ The class's public members change often
✗ The class has a constructor with many dependencies
✗ There are more than two or three implementations — a sign you need an abstract class
Summary #
- Implicit interfaces — every Dart class automatically defines an interface consisting of all its public members. No explicit
interfacekeyword needed.implementsrequires reimplementing all members — there’s no implementation inheritance. Good for defining type contracts that various different implementations can satisfy.abstract classis the most expressive way to define interfaces in Dart — it can combine abstract methods (contract) with concrete methods (default implementations) in one declaration.- Dart 3 introduced
interface classto explicitly preventextends, plus other modifiers (base,final,sealed) for stricter inheritance control.- Interfaces enable Dependency Inversion — depend on abstractions, not concrete implementations. This makes components testable in isolation and swappable without major changes.
- Interface Segregation — split large interfaces into small, focused ones. Components should depend only on the methods they actually use.
- Fakes/mocks from interfaces are the best way to unit test — fake implementations that behave per the test scenario without touching databases, networks, or external systems.
- Polymorphism through interfaces lets code work with various different implementations through the same type — without needing to know which implementation detail is active.
- Avoid monolithic interfaces — one interface with 20 methods forces every implementor to implement all of them, including irrelevant ones. Split them by responsibility.