← Back to list

Dart Generics Deleted 400 Lines of Flutter Boilerplate. Here’s the Pattern

Dart generics in Flutter repositories. How a single generic base class replaced six concrete implementations and cut the repetition from…

Ali Wajdan in Easy Flutter · 2026-05-12 10:01 · 52 claps · 4.4 min read
#flutter #dart #software-engineering #generics #repository-pattern
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

Six repository files, same structure, different type. The generic pattern replaced all of them.

Six repository files, same structure, different type. The generic pattern replaced all of them.

Dart Generics Deleted 400 Lines of Flutter Boilerplate. Here’s the Pattern

Dart generics in Flutter repositories. How a single generic base class replaced six concrete implementations and cut the repetition from the data layer for good.

Six months into a project, I opened the data layer and counted. Six repository classes. Each one had a create method, a getById method, an update method, a delete method, and a list method. The only difference between them was the type name.

AI Agent (Claude Code) had written 400 lines of nearly identical Dart for no reason other than not stopping to think about what I was actually doing.

Dart generics aren’t new, and the pattern I’m about to describe isn’t exotic. But most Flutter repositories I’ve seen, including the ones I wrote for the first couple of years, are concrete classes copy-pasted and modified to fit the model type. That approach works until it doesn’t. This is the version that does.

What Most Flutter Repository Implementations Look Like

The standard pattern in most Flutter projects I’ve worked on starts with a concrete repository per model type. You have a UserRepository, a ProductRepository, an OrderRepository. Each one maps to a collection or a table, and each one implements the same set of operations.

class UserRepository {
  final FirebaseFirestore _db;

  UserRepository(this._db);

  Future<User> getById(String id) async {
    final doc = await _db.collection('users').doc(id).get();
    return User.fromJson(doc.data()!);
  }

  Future<List<User>> list() async {
    final snapshot = await _db.collection('users').get();
    return snapshot.docs.map((d) => User.fromJson(d.data())).toList();
  }

  Future<void> create(User user) =>
      _db.collection('users').doc(user.id).set(user.toJson());

  Future<void> update(User user) =>
      _db.collection('users').doc(user.id).update(user.toJson());

  Future<void> delete(String id) =>
      _db.collection('users').doc(id).delete();
}

// ProductRepository is nearly identical:
class ProductRepository {
  final FirebaseFirestore _db;

  ProductRepository(this._db);

  Future<Product> getById(String id) async {
    final doc = await _db.collection('products').doc(id).get();
    return Product.fromJson(doc.data()!);
  }

  Future<List<Product>> list() async {
    final snapshot = await _db.collection('products').get();
    return snapshot.docs.map((d) => Product.fromJson(d.data())).toList();
  }

  Future<void> create(Product product) =>
      _db.collection('products').doc(product.id).set(product.toJson());

  Future<void> update(Product product) =>
      _db.collection('products').doc(product.id).update(product.toJson());

  Future<void> delete(String id) =>
      _db.collection('products').doc(id).delete();
}

That’s not architecture. That’s transcription. When a bug appears in the list logic, you fix it in six places. When you want to add a lastModified field to every write operation, you touch every repository. When the fetch logic gets a retry wrapper, you add it manually to each class.

The reason most people write it this way is that it’s the obvious approach. Each class is simple and self-contained. It works fine for the first two or three model types. By the sixth, it starts to feel wrong.

Two Dart repository files that are structurally identical. The only difference is the type.

Two Dart repository files that are structurally identical. The only difference is the type.

The Generic Base Repository That Replaced All of Them

The pattern I use now starts with an abstract base that parameterizes the model type. It requires the model to implement a simple interface so the repository knows how to serialize it and identify it.

// Every model that uses the generic repository must implement this
abstract class Identifiable {
  String get id;

  Map<String, dynamic> toJson();
}

// The generic base handles all CRUD operations
abstract class BaseFirestoreRepository<T extends Identifiable> {
  final FirebaseFirestore _db;
  final String collectionPath;

  // Subclasses provide the deserialization function
  T fromJson(Map<String, dynamic> json);

  BaseFirestoreRepository(this._db, this.collectionPath);

  Future<T> getById(String id) async {
    final doc = await _db.collection(collectionPath).doc(id).get();
    if (!doc.exists) throw NotFoundException(id);
    return fromJson(doc.data()!);
  }

  Future<List<T>> list() async {
    final snapshot = await _db.collection(collectionPath).get();
    return snapshot.docs.map((d) => fromJson(d.data())).toList();
  }

  Future<void> create(T item) =>
      _db.collection(collectionPath).doc(item.id).set(item.toJson());

  Future<void> update(T item) =>
      _db.collection(collectionPath).doc(item.id).update(item.toJson());

  Future<void> delete(String id) =>
      _db.collection(collectionPath).doc(id).delete();
}

With that base in place, each concrete repository shrinks to the model-specific logic only:

class UserRepository extends BaseFirestoreRepository<User> {
  UserRepository(FirebaseFirestore db) : super(db, 'users');

  @override
  User fromJson(Map<String, dynamic> json) => User.fromJson(json);

  // Model-specific queries live here, not in the base
  Future<List<User>> getActiveUsers() async {
    final snapshot = await db
        .collection(collectionPath)
        .where('status', isEqualTo: 'active')
        .get();

    return snapshot.docs.map((d) => fromJson(d.data())).toList();
  }
}

class ProductRepository extends BaseFirestoreRepository<Product> {
  ProductRepository(FirebaseFirestore db) : super(db, 'products');

  @override
  Product fromJson(Map<String, dynamic> json) => Product.fromJson(json);
}

The six repository classes that previously held 400 lines now hold about 60. The bug fix for the list logic goes in one place. The retry wrapper gets added once. The lastModified field goes into the base create and update methods and applies to every model automatically.

Model-specific queries like getActiveUsers still live in the concrete class, where they belong. The generic base doesn’t try to handle filtering or sorting logic, because those depend on the model. It only handles the operations that are genuinely the same for every type.

Where This Pattern Gets Uncomfortable

Dart generics have a learning curve, and abstract class hierarchies add indirection. A developer unfamiliar with the pattern will look at UserRepository, see that it extends a base class, go to the base class, see a type parameter and an abstract method, and take a few minutes to put it together. That’s real overhead for someone new.

The other trade-off is type constraints. The Identifiable interface works cleanly when all your models have an id field and a toJson method. When they don’t, or when the serialization logic differs meaningfully between models, the constraint becomes a source of friction rather than clarity.

My rule: if you have three or more model types with identical CRUD operations and the same data source, the generic base is worth it. If you have two model types or significantly different data access patterns per model, stick with the concrete classes. The pattern earns its keep through repetition. If there’s no repetition to eliminate, there’s no reason to introduce the abstraction.

  • Generic repositories make bug fixes and additions apply to every model automatically
  • The Identifiable interface is the price of admission: every model needs an id and a toJson
  • Model-specific queries belong in concrete subclasses, not the base
  • For two or three model types, concrete classes are fine; for six or more, the duplication becomes a maintenance problem
  • Introducing generics adds indirection; weigh that against the repetition you’re eliminating

Every project I’ve started since has used this pattern from day one for Firestore. The 400 lines I wrote before weren’t wasted. I needed to feel the repetition to understand why the abstraction was worth its cost.

If you’ve landed on a different approach for reducing Flutter repository boilerplate, I’d like to hear it.

More posts like this are in the works. Follow if you want them in your feed.

AUTHOR BIO Ali Wajdan is a Senior Mobile Engineer with 5+ years of experience shipping apps used by people across iOS and Android. I build cross-platform mobile apps, AI-integrated backends, and everything in between.

Portfolio: https://aliwajdan.com LinkedIn: https://www.linkedin.com/in/aliwajdanpasha GitHub: https://github.com/aliwajdan453


메타데이터
post_id
b6bedc5ce355
slug
dart-generics-deleted-400-lines-of-flutter-boilerplate-heres-the-pattern-b6bedc5ce355
url
https://medium.com/easy-flutter/dart-generics-deleted-400-lines-of-flutter-boilerplate-heres-the-pattern-b6bedc5ce355
canonical_url
https://medium.com/easy-flutter/dart-generics-deleted-400-lines-of-flutter-boilerplate-heres-the-pattern-b6bedc5ce355
author_url
https://medium.com/@aliwajdan
status
ok
fetched_at
2026-06-11 16:11:38