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

