Code Generation with datum_generator#
Datum provides a powerful code generator that automatically creates boilerplate code for your entities, significantly reducing development time and eliminating common errors.
Why Use Code Generation?#
Writing manual serialization, deserialization, diff tracking, and copy methods for every entity is:
- Time-consuming: Repetitive boilerplate code for every field
- Error-prone: Easy to miss fields or make type conversion mistakes
- Hard to maintain: Changes to your entity require updating multiple methods
- Inconsistent: Different developers may implement methods differently
The datum_generator package solves these problems by automatically generating:
- ✅
toDatumMap()- Serialization with automatic snake_case conversion - ✅
fromMap()- Type-safe deserialization with proper null handling - ✅
diff()- Change tracking between entity versions -
✅
copyWith()andcopyWithAll()- Immutable updates with automatic version incrementing - ✅
operator ==andhashCode- Proper equality comparisons - ✅ Helper methods for date parsing and list equality
Add datum_generator as a dependency (for annotations) and a dev dependency (for the builder) in your
pubspec.yaml:
dependencies:
datum: ^1.0.3
datum_generator: ^1.0.0
dev_dependencies:
build_runner: ^2.4.0
datum_generator: ^1.0.0
Then run:
flutter pub get
Quick Start#
1. Annotate Your Entity#
Add the @DatumSerializable annotation, set generateMixin: true, and include the part directive:
import 'package:datum/datum.dart';
import 'package:datum_generator/datum_generator.dart';
part 'task.g.dart';
@DatumSerializable(tableName: 'tasks', generateMixin: true)
class Task extends DatumEntity with _$TaskMixin {
@override
final String id;
@override
final String userId;
final String title;
final String? description;
final bool isCompleted;
@override
final DateTime createdAt;
@override
final DateTime modifiedAt;
@override
final int version;
@override
final bool isDeleted;
const Task({
required this.id,
required this.userId,
required this.title,
this.description,
this.isCompleted = false,
required this.createdAt,
required this.modifiedAt,
this.version = 1,
this.isDeleted = false,
});
}
By using the generated mixin (with _$TaskMixin), you no longer need to manually override
toDatumMap, diff, copyWith, operator ==, or hashCode. The generator handles everything!
2. Run the Generator#
Execute the build runner to generate the code:
flutter pub run build_runner build
For continuous generation during development:
flutter pub run build_runner watch
This creates a task.g.dart file with all the boilerplate code!
3. Use the Generated Code#
The generated file includes:
// task.g.dart (generated)
extension $TaskDatum on Task {
static const String tableName = 'tasks';
Map<String, dynamic> datumToMap({MapTarget target = MapTarget.local}) {
// Automatic serialization with snake_case conversion
}
Map<String, dynamic>? datumDiff(DatumEntityInterface oldVersion) {
// Automatic change tracking
}
Task copyWith({DateTime? modifiedAt, int? version, bool? isDeleted}) {
// Metadata-only copy
}
Task copyWithAll({/* all fields */}) {
// Full copy with version incrementing
}
bool datumEquals(Task other) {
// Field-by-field equality
}
int get datumHashCode {
// Proper hash code generation
}
}
Task _$TaskFromMap(Map<String, dynamic> map) {
// Type-safe deserialization
}
Available Annotations#
@DatumSerializable#
Marks a class for code generation.
@DatumSerializable(tableName: 'custom_table_name')
class MyEntity extends DatumEntity {
// ...
}
Parameters:
tableName(optional): Custom table name. Defaults to snake_case of class name.-
generateMixin(optional, default:false): Iftrue, generates a mixin that implements all requiredDatumEntitymethods.
@DatumIgnore#
Excludes a field from serialization (but still includes it in copyWith and equality checks).
class User extends DatumEntity {
final String email;
@DatumIgnore()
final String? temporaryToken; // Won't be serialized to database
// ...
}
Use cases:
- Computed properties
- Temporary runtime data
- Sensitive information that shouldn't be persisted
- UI state that doesn't belong in the database
@DatumField#
Specifies a custom database field name.
class Product extends DatumEntity {
@DatumField('product_name')
final String name;
@DatumField('unit_price')
final double price;
// ...
}
Use cases:
- Matching existing database schemas
- Following specific naming conventions
- Avoiding reserved keywords
Supported Types#
The generator automatically handles these types:
Primitives#
String,int,double,bool- Nullable variants:
String?,int?, etc.
Dates#
-
DateTime- Automatically converts between:- Milliseconds (local storage)
- ISO8601 strings (remote storage)
Flutter Types#
Color- Serialized as ARGB integerOffset- Serialized as{x: double, y: double}List<Offset>- Serialized as array of offset maps
Collections#
List<T>- With proper equality checkingMap<String, dynamic>- Nested data structures
Example with Complex Types#
import 'dart:ui';
import 'package:datum/datum.dart';
part 'drawing.g.dart';
@DatumSerializable()
class Drawing extends DatumEntity {
final Color backgroundColor;
final List<Offset> points;
final double strokeWidth;
final Map<String, dynamic>? metadata;
// ... constructor and other methods
}
Generated code handles:
Color→int(ARGB format)List<Offset>→List<Map<String, dynamic>>- Proper type conversions in both directions
Advanced Usage#
Relational Entities#
The generator works seamlessly with RelationalDatumEntity:
```dart
@DatumSerializable(tableName: 'paint_canvases', generateMixin: true)
class PaintCanvas extends RelationalDatumEntity with _$PaintCanvasMixin {
@override
final String id;
final String title;
final int strokeCount;
@override
final DateTime createdAt;
@override
final DateTime modifiedAt;
@override
final int version;
@override
final bool isDeleted;
const PaintCanvas({
required this.id,
required this.userId,
required this.title,
this.strokeCount = 0,
required this.createdAt,
required this.modifiedAt,
this.version = 1,
this.isDeleted = false,
});
@override
Map<String, Relation> get relations => datumRelations;
@HasManyRelation<PaintStroke>('canvasId', cascadeDelete: 'cascade')
final List<PaintStroke>? _strokes = null;
factory PaintCanvas.fromMap(Map<String, dynamic> map) {
return _$PaintCanvasFromMap(map);
}
}
Custom Serialization Logic#
If you need custom logic for specific fields, you can still use the generator for most fields:
@DatumSerializable()
class CustomEntity extends DatumEntity {
final String normalField;
@DatumIgnore()
final ComplexType customField;
// Override toDatumMap to add custom field
@override
Map<String, dynamic> toDatumMap({MapTarget target = MapTarget.local}) {
final map = datumToMap(target: target);
map['custom_field'] = customField.toJson();
return map;
}
// Override fromMap to parse custom field
factory CustomEntity.fromMap(Map<String, dynamic> map) {
final entity = _$CustomEntityFromMap(map);
return entity.copyWithAll(
customField: ComplexType.fromJson(map['custom_field']),
);
}
}
Generated Method Details#
datumToMap()#
Converts the entity to a map with automatic field name conversion:
final task = Task(
id: '1',
userId: 'user1',
title: 'Buy groceries',
createdAt: DateTime.now(),
modifiedAt: DateTime.now(),
);
// For local storage (milliseconds)
final localMap = task.datumToMap(target: MapTarget.local);
// {
// 'id': '1',
// 'user_id': 'user1',
// 'title': 'Buy groceries',
// 'createdAt': 1704729600000,
// 'modifiedAt': 1704729600000,
// }
// For remote storage (ISO8601)
final remoteMap = task.datumToMap(target: MapTarget.remote);
// {
// 'id': '1',
// 'user_id': 'user1',
// 'title': 'Buy groceries',
// 'createdAt': '2024-01-08T12:00:00.000Z',
// 'modifiedAt': '2024-01-08T12:00:00.000Z',
// }
datumDiff()#
Tracks changes between versions:
final oldTask = Task(
id: '1',
userId: 'user1',
title: 'Buy groceries',
isCompleted: false,
createdAt: DateTime.now(),
modifiedAt: DateTime.now(),
);
final newTask = oldTask.copyWithAll(
title: 'Buy groceries and cook',
isCompleted: true,
);
final changes = newTask.datumDiff(oldTask);
// {
// 'title': 'Buy groceries and cook',
// 'is_completed': true,
// 'modifiedAt': '2024-01-08T12:05:00.000Z',
// 'version': 2,
// }
copyWithAll()#
Creates a copy with automatic version incrementing:
final task = Task(
id: '1',
userId: 'user1',
title: 'Original',
version: 1,
createdAt: DateTime.now(),
modifiedAt: DateTime.now(),
);
final updated = task.copyWithAll(
title: 'Updated',
isCompleted: true,
);
print(updated.version); // 2 (automatically incremented)
print(updated.title); // 'Updated'
datumEquals() and datumHashCode#
Proper equality and hashing:
final task1 = Task(id: '1', userId: 'user1', title: 'Task', ...);
final task2 = Task(id: '1', userId: 'user1', title: 'Task', ...);
final task3 = Task(id: '1', userId: 'user1', title: 'Different', ...);
print(task1 == task2); // true (all fields match)
print(task1 == task3); // false (title differs)
final set = {task1, task2};
print(set.length); // 1 (task2 is considered duplicate)
Best Practices#
1. Always Use Part Directive#
// ✅ Correct
part 'my_entity.g.dart';
// ❌ Wrong - will cause build errors
// Missing part directive
2. Use Const Constructors#
// ✅ Preferred
const Task({
required this.id,
required this.userId,
// ...
});
// ⚠️ Works but less efficient
Task({
required this.id,
required this.userId,
// ...
});
3. Implement Equality Using Generated Methods#
// ✅ Correct
@override
bool operator ==(Object other) => other is Task && datumEquals(other);
@override
int get hashCode => datumHashCode;
// ❌ Wrong - manual implementation may miss fields
@override
bool operator ==(Object other) {
return other is Task && other.id == id && other.title == title;
}
4. Run Generator After Schema Changes#
# Clean build cache if you encounter issues
flutter pub run build_runner clean
# Rebuild with conflict resolution
flutter pub run build_runner build --delete-conflicting-outputs
5. Commit Generated Files#
Always commit .g.dart files to version control for consistency across team members and CI/CD pipelines.
Troubleshooting#
Generator Not Running#
Problem: No .g.dart file is created.
Solutions:
- Ensure you have the
partdirective:part 'filename.g.dart'; - Verify the class is annotated:
@DatumSerializable() - Check that
datum_generatoris indev_dependencies - Run
flutter pub getto install dependencies
Build Errors#
Problem: Build fails with errors.
Solutions:
# Clean and rebuild
flutter pub run build_runner clean
flutter pub run build_runner build --delete-conflicting-outputs
Type Errors in Generated Code#
Problem: Generated code has type mismatches.
Solutions:
- Ensure all fields have explicit types (avoid
varordynamic) - Use nullable types correctly (
String?vsString) - Check that custom types are properly imported
Missing Fields in Generated Methods#
Problem: Some fields are not included in generated code.
Solutions:
- Check if fields are marked with
@DatumIgnore() - Verify fields are not
staticorsynthetic - Ensure fields are instance variables, not getters
Conflicts with Manual Implementation#
Problem: Generated methods conflict with existing code.
Solutions:
- Remove manual implementations of
toDatumMap,fromMap, etc. - Use generated methods by calling
datumToMap(),_$EntityFromMap(), etc. - For custom logic, override and call generated methods:
@override
Map<String, dynamic> toDatumMap({MapTarget target = MapTarget.local}) {
final map = datumToMap(target: target);
// Add custom logic
map['computed_field'] = someComputation();
return map;
}
Performance Considerations#
The code generator produces highly optimized code:
- No reflection: All code is generated at compile time
- Type-safe: No runtime type checking overhead
- Efficient: Direct field access without intermediate representations
- Minimal overhead: Generated code is as fast as hand-written code
Comparison: Manual vs Generated#
Manual Implementation (Before)#
class Task extends DatumEntity {
// 50+ lines of boilerplate per entity
@override
Map<String, dynamic> toDatumMap({MapTarget target = MapTarget.local}) {
final map = {
'id': id,
'user_id': userId,
'title': title,
'description': description,
'is_completed': isCompleted,
'is_deleted': isDeleted,
'version': version,
};
if (target == MapTarget.remote) {
map['createdAt'] = createdAt.toIso8601String();
map['modifiedAt'] = modifiedAt.toIso8601String();
} else {
map['createdAt'] = createdAt.millisecondsSinceEpoch;
map['modifiedAt'] = modifiedAt.millisecondsSinceEpoch;
}
return map;
}
factory Task.fromMap(Map<String, dynamic> map) {
return Task(
id: (map['id'] ?? '') as String,
userId: (map['userId'] ?? map['user_id'] ?? '') as String,
title: (map['title'] ?? '') as String,
description: map['description'] as String?,
isCompleted: (map['isCompleted'] ?? map['is_completed'] ?? false) as bool,
createdAt: _parseDate(map['createdAt'] ?? map['created_at']),
modifiedAt: _parseDate(map['modifiedAt'] ?? map['modified_at']),
isDeleted: (map['isDeleted'] ?? map['is_deleted'] ?? false) as bool,
version: (map['version'] ?? 1) as int,
);
}
// ... more boilerplate for diff, copyWith, equality, etc.
}
Generated Implementation (After)#
part 'task.g.dart';
@DatumSerializable()
class Task extends DatumEntity {
// Just 10 lines to use generated code
@override
Map<String, dynamic> toDatumMap({MapTarget target = MapTarget.local}) {
return datumToMap(target: target);
}
factory Task.fromMap(Map<String, dynamic> map) {
return _$TaskFromMap(map);
}
@override
bool operator ==(Object other) => other is Task && datumEquals(other);
@override
int get hashCode => datumHashCode;
}
Benefits:
- ✅ 80% less code to write and maintain
- ✅ Zero chance of missing fields
- ✅ Consistent implementation across all entities
- ✅ Automatic updates when fields change
Next Steps#
- Define Entities: Learn more about entity definition patterns
- Implement Adapters: Set up local and remote adapters
- Work with Relationships: Use code generation with relational entities
- Query Data: Learn how to query generated entities