Skip to main content

Best Practices

Recommended patterns, common pitfalls, and optimization strategies for using Ormed effectively.

Query Optimization

Avoid N+1 Queries

❌ Bad: Lazy loading in loops

// BAD: N+1 problem - one query per user
Future<void> nPlusOneBad(DataSource dataSource) async {
final users = await dataSource.query<$User>().get();

for (final user in users) {
// This triggers a query for EACH user!
await user.load(['posts']);
print('${user.name} has ${user.posts?.length} posts');
}
}

✅ Good: Eager load relations upfront

// GOOD: Eager loading - just 2 queries total
Future<void> nPlusOneGood(DataSource dataSource) async {
final users = await dataSource
.query<$User>()
.with_(['posts']) // Load all posts in one query
.get();

for (final user in users) {
// Already loaded, no additional query
print('${user.name} has ${user.posts?.length} posts');
}
}

Use Aggregate Loaders

❌ Bad: Loading full collections to count

// BAD: Loads all related records just to count
Future<void> aggregateBad(DataSource dataSource) async {
final user = await dataSource.query<$User>().with_(['posts']).first();
final postCount = user?.posts?.length ?? 0;
}

✅ Good: Use aggregate loaders

// GOOD: Uses database to count
Future<void> aggregateGood(DataSource dataSource) async {
final user = await dataSource.query<$User>().first();
await user?.loadCount(['posts']);
final postCount = user?.getAttribute<int>('posts_count') ?? 0;
}

Select Only What You Need

❌ Bad: Selecting all columns

// BAD: Loads all columns when you only need emails
Future<void> selectBad(DataSource dataSource) async {
final users = await dataSource.query<$User>().get();
final emails = users.map((u) => u.email).toList();
}

✅ Good: Use pluck() for single columns

// GOOD: Only select what you need
Future<void> selectGood(DataSource dataSource) async {
final emails = await dataSource.query<$User>().pluck<String>('email');
}

Use Pagination

Future<void> paginationExample(DataSource dataSource) async {
// Use pagination for large datasets
final page = 1;
final perPage = 20;

final users = await dataSource
.query<$User>()
.orderBy('id')
.limit(perPage)
.offset((page - 1) * perPage)
.get();
}

Index Frequently Queried Columns

// Ensure indexes on frequently queried columns
// In your migration:
// schema.create('users', (table) {
// table.index(['email']);
// table.index(['created_at']);
// table.index(['status', 'created_at']); // Composite for common filters
// });

Relation Loading Strategies

When to Use Eager Loading

✅ Use when you know you'll need the relations:

  • List views showing related data
  • APIs returning nested resources
  • Reports and dashboards
// Use eager loading when:
// - Processing multiple records
// - You KNOW you need the relation data
// - Building API responses with nested data
Future<void> whenToUseEagerLoading(DataSource dataSource) async {
final posts = await dataSource.query<$Post>().with_([
'author',
'comments',
]).get();

// Build response with all data already loaded
for (final post in posts) {
print('${post.title} by ${post.author?.name}');
}
}

When to Use Lazy Loading

✅ Use when relations are conditionally needed:

  • Detail views with permission-based data
  • Optional expansions based on request parameters
// Use lazy loading when:
// - You might not need the relation
// - Processing single records
// - Conditional logic determines if relation is needed
Future<void> whenToUseLazyLoading(DataSource dataSource) async {
final post = await dataSource.query<$Post>().find(1);

if (post != null && post.getAttribute<bool>('featured') == true) {
// Only load if needed
await post.load(['author', 'comments']);
}
}

Use loadMissing for Hybrid Scenarios

// Use loadMissing to avoid reloading
Future<void> loadMissingExample(DataSource dataSource) async {
final post = await dataSource.query<$Post>().with_(['author']).first();

if (post != null) {
// Won't reload author, only loads comments
await post.loadMissing(['author', 'comments']);
}
}

Prevent Lazy Loading in Development

// Enable lazy loading prevention during development
void preventLazyLoadingExample() {
// In main.dart or test setup
// if (kDebugMode) {
// Model.preventLazyLoading();
// }

// Now accessing unloaded relations throws an exception
// This helps catch N+1 issues during development
}

Model Design

Extend Model for Full Features

// Add business logic to your models
// In user.dart:
// extension UserExtensions on User {
// String get displayName => name ?? email.split('@').first;
// bool get isVerified => emailVerifiedAt != null;
// bool get canPost => isVerified && active;
// }

Benefits:

  • Lazy loading: await user.load(['posts'])
  • Relation mutations: user.associate('role', role)
  • Persistence: await user.save()
  • Attributes: user.getAttribute('computed_value')

Use Immutable Models

// Models should be immutable - use tracked models for changes
Future<void> immutableModelExample(DataSource dataSource) async {
final user = await dataSource.query<$User>().find(1);

if (user != null) {
// Don't modify original, use setAttribute on tracked model
user.setAttribute('name', 'New Name');
await dataSource.repo<$User>().update(user);

// Or use update DTO
await dataSource.repo<$User>().update(
UserUpdateDto(name: 'New Name'),
where: {'id': 1},
);
}
}

Benefits: Thread-safe, predictable, easy to test.

Use Soft Deletes Wisely

// Use soft deletes wisely
// Good for:
// - Audit trails
// - Recoverable deletions
// - Legal/compliance requirements

// Avoid for:
// - High-volume ephemeral data
// - Storage-constrained environments
// - Data without recovery needs

Error Handling

Use Typed Exceptions

Future<void> typedExceptionsExample(DataSource dataSource, String email) async {
try {
final user = await dataSource
.query<$User>()
.whereEquals('email', email)
.firstOrFail();
} on ModelNotFoundException catch (e) {
print('User not found: $e');
}
}

Validate Before Save

Define validation rules on your model:

(table: 'validated_users')
class ValidatedUser extends Model<ValidatedUser> {
const ValidatedUser({
required this.id,
required this.email,
required this.age,
});

(isPrimaryKey: true, autoIncrement: true)
final int id;
final String email;
final int age;

void validate() {
if (!email.contains('@')) {
throw ArgumentError('Invalid email format');
}
if (age < 0 || age > 150) {
throw ArgumentError('Invalid age');
}
}
}

Call validation before persistence:

Future<void> validateBeforeSaveExample(
DataSource dataSource,
ValidatedUser user,
) async {
// Before inserting
user.validate();
await dataSource.repo<$ValidatedUser>().insert(user);
}

Use Transactions for Critical Operations

Future<void> transactionExample(
DataSource dataSource,
int fromAccountId,
int toAccountId,
double amount,
double sourceBalance,
double destBalance,
) async {
await dataSource.transaction(() async {
await dataSource.query<$User>().whereEquals('id', fromAccountId).update({
'balance': sourceBalance - amount,
});

await dataSource.query<$User>().whereEquals('id', toAccountId).update({
'balance': destBalance + amount,
});
});
}

Testing

Use In-Memory Databases

// Use in-memory databases for fast, isolated tests
// driver: SqliteDriverAdapter.inMemory()

Use Factories for Test Data

Future<void> testFactoriesExample(DataSource dataSource) async {
final userFactory = Model.factory<User>();

for (var i = 0; i < 100; i++) {
await userFactory
.seed(i)
.withField('email', 'user_$i@test.com')
.create(context: dataSource.context);
}

// expect(await dataSource.query<$User>().count(), equals(100));
}

Security

Never Use Raw User Input

❌ Bad: SQL injection risk

// BAD: SQL injection risk
Future<void> sqlInjectionBadExample(
DataSource dataSource,
String userInput,
) async {
// DON'T DO THIS!
// final users = await dataSource.query<$User>()
// .whereRaw("email = '\$userInput'")
// .get();
}

✅ Good: Parameterized queries

// GOOD: Parameterized queries
Future<void> sqlInjectionGoodExample(
DataSource dataSource,
String userInput,
) async {
final users = await dataSource
.query<$User>()
.whereEquals('email', userInput)
.get();
}

Validate Relations Before Mutations

Future<void> validateRelationsExample(
DataSource dataSource,
Post post,
User author,
) async {
final validAuthor = await dataSource
.query<$User>()
.whereEquals('id', author.id)
.whereEquals('active', true)
.firstOrNull();

if (validAuthor == null) {
throw Exception('Invalid author');
}

post.associate('author', validAuthor);
await post.save();
}

Use Scopes for Multi-Tenancy

void scopesMultitenancyExample(QueryContext context, int currentTenantId) {
context.scopeRegistry.registerScope<$Post>((query) {
query.whereEquals('tenant_id', currentTenantId);
});

// All queries automatically filtered
// final posts = await dataSource.query<$Post>().get();
// SQL: SELECT * FROM posts WHERE tenant_id = ?
}

Limit Exposed Fields

Create DTOs for API responses:

class UserDto {
final int id;
final String email;
// No passwordHash or apiToken

UserDto({required this.id, required this.email});

factory UserDto.fromModel(User user) =>
UserDto(id: user.id, email: user.email);
}

Future<List<UserDto>> getUserDtosExample(DataSource dataSource) async {
final users = await dataSource.query<$User>().get();
return users.map(UserDto.fromModel).toList();
}

Summary Checklist

Query Performance

  • Use eager loading instead of lazy loading in loops
  • Use aggregate loaders instead of loading full collections
  • Add database indexes for frequently queried columns
  • Use pagination for large result sets

Model Design

  • Extend Model<T> for full feature support
  • Use immutable models
  • Add validation methods
  • Use soft deletes only when needed

Error Handling

  • Catch specific exceptions
  • Use transactions for critical operations
  • Validate data before persistence

Security

  • Never concatenate user input in queries
  • Validate relations before mutations
  • Use scopes for multi-tenancy
  • Create DTOs for API responses