You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter领域驱动设计下Firestore ID在模型与实体的最优处理方式是什么?

问题描述

我正在学习Flutter应用的领域驱动设计(DDD),了解到模型用于基础设施层与用例层之间,实体用于用例层与UI层之间。

假设我的应用处理书籍数据,存储在Cloud Firestore中,我定义了一个简单的BookEntity,包含id和title:

@freezed
class BookEntity with _$BookEntity {
  const factory BookEntity({
    required String firestoreId, // Firestore文档ID
    required String title,
  }) = _BookEntity;
}

我认为文档ID应该包含在实体中,因为修改Firestore中的书籍时需要知道文档引用,对吗?

Firestore的ID并不属于数据本身。读取数据库时的代码如下:

// 未测试代码
FirebaseFirestore
    .instance
    .collection('books')
    .doc('uXSin0z3gqPHwhVLCP98')
    .get()
    .then((DocumentSnapshot<Map<String, dynamic>> snapshot) {
  snapshot.id;      // -> 'uXSin0z3gqPHwhVLCP98'
  snapshot.data();  // -> {title: 'To Kill a Mockingbird', price: 11.69, year: 1960}
});

我需要在仓库层创建模型来结合ID与数据,模型结构和实体类似:

@freezed
class BookModel with _$BookModel {
  const factory BookModel({
    required String firestoreId,
    required String title,
  }) = _BookModel;
}

从Firestore获取数据时,我会创建模型:

BookModel(
  firestoreId: snapshot.id,
  title: snapshot.data()?['title'],
);

之后将其转换为供UI使用的BookEntity。

但反向流程(创建新书)存在问题:UI层和领域层不知道Firestore文档ID,因此必须将BookEntity和BookModel中的id设为可选,修改后的代码如下:

@freezed
class BookEntity with _$BookEntity {
  const factory BookEntity({
    String? firestoreId,
    required String title,
  }) = _BookEntity;
}

@freezed
class BookModel with _$BookModel {
  const factory BookModel({
    String? firestoreId,
    required String title,
  }) = _BookModel;
}

现在的问题是:每当访问来自Firestore的BookEntity的firestoreId字段时,都需要检查是否为null,但实际上该字段不可能为null(来自Firestore的数据必然有ID)。这导致我要么写大量空检查,要么使用!操作符(我不喜欢这样)。

简言之,**上游(Firebase -> UI)和下游(UI -> Firebase)**流程对firestoreId字段有不同要求:前者需要String类型,后者需要String?类型。请问处理这种情况的最佳且最简洁的方式是什么?


解决方案

针对这种上下流程对ID的不同要求,有几种干净且符合DDD思想的处理方式:

1. 拆分实体/模型:区分已持久化和待创建的对象

直接创建两组类,分别对应「已存在于Firestore的书籍」和「待创建的新书」,从根源上避免空值的困扰:

实体层

// 已持久化的书籍,ID非空
@freezed
class ExistingBookEntity with _$ExistingBookEntity {
  const factory ExistingBookEntity({
    required String firestoreId,
    required String title,
  }) = _ExistingBookEntity;
}

// 待创建的新书,无ID
@freezed
class NewBookEntity with _$NewBookEntity {
  const factory NewBookEntity({
    required String title,
  }) = _NewBookEntity;
}

模型层

// 对应Firestore已存在的文档
@freezed
class ExistingBookModel with _$ExistingBookModel {
  const factory ExistingBookModel({
    required String firestoreId,
    required String title,
  }) = _ExistingBookModel;
}

// 对应待写入Firestore的数据
@freezed
class NewBookModel with _$NewBookModel {
  const factory NewBookModel({
    required String title,
  }) = _NewBookModel;
}

使用场景

  • 上游流程(Firebase→UI):将ExistingBookModel转为ExistingBookEntity,UI层拿到的实体自带非空ID,无需空检查。
  • 下游流程(UI→Firebase):UI层传递NewBookEntity到用例层,再转为NewBookModel写入Firestore;Firestore生成ID后,再转为ExistingBookEntity回传给UI更新状态。

这种方式类型安全,逻辑清晰,完全避免空值判断。

2. 使用Freezed密封类(Union Type)

利用Freezed的联合类型特性,在同一个类下定义两种状态,既保持类的关联性,又能区分有无ID的场景:

实体层

@freezed
class BookEntity with _$BookEntity {
  // 已持久化的书籍,ID非空
  const factory BookEntity.withId({
    required String firestoreId,
    required String title,
  }) = _BookWithId;

  // 待创建的新书,无ID
  const factory BookEntity.withoutId({
    required String title,
  }) = _BookWithoutId;
}

模型层

@freezed
class BookModel with _$BookModel {
  const factory BookModel.withId({
    required String firestoreId,
    required String title,
  }) = _ModelWithId;

  const factory BookModel.withoutId({
    required String title,
  }) = _ModelWithoutId;
}

使用场景

  • 处理来自Firebase的数据时,创建BookModel.withId,再转为BookEntity.withId,UI层使用时通过when方法分支处理:
// UI层使用示例
bookEntity.when(
  withId: (firestoreId, title) {
    // 这里firestoreId一定非空,直接使用
    return Text('$title (ID: $firestoreId)');
  },
  withoutId: (title) {
    // 处理新书场景
    return Text('待保存:$title');
  },
);
  • 创建新书时,UI层传递BookEntity.withoutId,用例层转为BookModel.withoutId写入Firestore,生成ID后再转为BookEntity.withId更新状态。

这种方式不用拆分多个类,通过状态分支实现类型安全,代码更紧凑。

3. 仓库层统一处理空值断言

如果不想拆分类,可以在仓库层严格把控ID的非空性,确保上游传递给用例层的实体ID一定非空,下游创建时单独处理:

实体和模型保持可选ID

@freezed
class BookEntity with _$BookEntity {
  const factory BookEntity({
    String? firestoreId,
    required String title,
  }) = _BookEntity;
}

@freezed
class BookModel with _$BookModel {
  const factory BookModel({
    String? firestoreId,
    required String title,
  }) = _BookModel;
}

仓库层处理逻辑

class BookRepository {
  // 从Firestore获取书籍,严格返回带非空ID的实体
  Future<BookEntity> getBook(String documentId) async {
    final snapshot = await FirebaseFirestore.instance.collection('books').doc(documentId).get();
    final model = BookModel(
      firestoreId: snapshot.id,
      title: snapshot.data()!['title'],
    );
    // 这里直接断言,因为Firestore返回的文档一定有ID,若出现异常则属于程序错误
    return BookEntity(
      firestoreId: model.firestoreId!,
      title: model.title,
    );
  }

  // 创建新书,接收无ID的实体
  Future<BookEntity> createBook(BookEntity newBook) async {
    // 让Firestore自动生成ID
    final docRef = await FirebaseFirestore.instance.collection('books').add({
      'title': newBook.title,
    });
    // 生成ID后返回带ID的实体
    return newBook.copyWith(firestoreId: docRef.id);
  }
}

这种方式适合不想改动类结构的场景,把空断言的逻辑集中在仓库层,UI和用例层拿到的上游实体ID都是可靠的,无需额外检查。


内容的提问来源于stack exchange,提问作者user20874439

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.02 18:52:37