Skip to main content

Best Practices and Patterns

Follow these patterns and best practices to build maintainable, scalable Nest Dart applications.

Module Organization

Feature-Based Module Structure

Organize modules by feature, not by layer:
// Good: Feature-based organization
lib/
├── modules/
│   ├── user/
│   │   ├── user_module.dart
│   │   ├── user_service.dart
│   │   ├── user_repository.dart
│   │   └── models/
│   │       └── user.dart
│   ├── auth/
│   │   ├── auth_module.dart
│   │   ├── auth_service.dart
│   │   └── guards/
│   │       └── jwt_guard.dart
│   └── todo/
│       ├── todo_module.dart
│       ├── todo_service.dart
│       └── todo_provider.dart
├── shared/
│   ├── core_module.dart
│   └── database_module.dart
└── app_module.dart

// Avoid: Layer-based organization
lib/
├── services/
│   ├── user_service.dart
│   ├── auth_service.dart
│   └── todo_service.dart
├── repositories/
│   └── user_repository.dart
└── modules/
    └── app_module.dart

Module Responsibility

Each module should have a single, well-defined responsibility:
// Good: Focused modules
class UserModule extends Module {
  @override
  List<Module> get imports => [DatabaseModule()];

  @override
  Future<void> providers(Locator locator) async {
    locator.registerSingleton<UserRepository>(
      UserRepository(locator.get<Database>()),
    );
    locator.registerSingleton<UserService>(
      UserService(locator.get<UserRepository>()),
    );
  }

  @override
  List<Type> get exports => [UserService];
}

// Avoid: "God modules" that do everything
class EverythingModule extends Module {
  // Too many responsibilities!
  // Handles users, auth, todos, emails, etc.
}

When to Export Services

Export Public APIs Only

Only export services that other modules need to consume:
class UserModule extends Module {
  @override
  Future<void> providers(Locator locator) async {
    // Private: Only used within this module
    locator.registerSingleton<UserRepository>(
      UserRepository(locator.get<Database>()),
    );
    locator.registerSingleton<UserValidator>(
      UserValidator(),
    );
    
    // Public: Used by other modules
    locator.registerSingleton<UserService>(
      UserService(
        locator.get<UserRepository>(),
        locator.get<UserValidator>(),
      ),
    );
  }

  @override
  List<Type> get exports => [
    UserService, // Only export the public API
    // UserRepository and UserValidator remain private
  ];
}
Exporting creates a public API contract. Only export what’s necessary to keep implementation details hidden.

Re-Exporting Dependencies

Use re-exports to simplify imports for consuming modules:
class CoreModule extends Module {
  @override
  Future<void> providers(Locator locator) async {
    final prefs = await SharedPreferences.getInstance();
    locator.registerSingleton<SharedPreferences>(prefs);
    locator.registerSingleton<Client>(Client());
    locator.registerSingleton<ConfigService>(
      ConfigService(prefs),
    );
  }

  @override
  List<Type> get exports => [
    Client,         // Re-export for HTTP calls
    ConfigService,  // Re-export for configuration
    // SharedPreferences is private - use ConfigService instead
  ];
}

Service Scope Decisions

Singleton vs Factory

From packages/nest_core/lib/src/module.dart:241-305:
// Singleton: One instance shared across the application
locator.registerSingleton<DatabaseService>(
  DatabaseService(),
);

// Lazy Singleton: Created on first access
locator.registerLazySingleton<ConfigService>(
  () => ConfigService(),
);

// Factory: New instance on every access
locator.registerFactory<RequestLogger>(
  () => RequestLogger(),
);

When to Use Each Scope

class AppModule extends Module {
  @override
  Future<void> providers(Locator locator) async {
    // Singleton: Shared state, expensive to create
    locator.registerSingleton<DatabaseService>(
      DatabaseService(),
    );
    
    // Lazy Singleton: Rarely used, expensive to create
    locator.registerLazySingleton<EmailService>(
      () => EmailService(config: locator.get<Config>()),
    );
    
    // Factory: Stateful, should not be shared
    locator.registerFactory<UserSessionService>(
      () => UserSessionService(),
    );
    
    // Factory: Cheap to create, need fresh instance
    locator.registerFactory<DateTimeProvider>(
      () => DateTimeProvider(),
    );
  }
}
Use singletons for stateless services and factories for stateful services or when you need a fresh instance.

Error Handling

Custom Exceptions

Create domain-specific exceptions for better error handling:
class UserNotFoundException implements Exception {
  final int userId;
  UserNotFoundException(this.userId);
  
  @override
  String toString() => 'User with id $userId not found';
}

class ValidationException implements Exception {
  final Map<String, List<String>> errors;
  ValidationException(this.errors);
  
  @override
  String toString() => 'Validation failed: $errors';
}

class UserService {
  Future<User> getUser(int id) async {
    final user = await repository.findById(id);
    if (user == null) {
      throw UserNotFoundException(id);
    }
    return user;
  }
  
  Future<User> createUser(CreateUserDto dto) async {
    final errors = validator.validate(dto);
    if (errors.isNotEmpty) {
      throw ValidationException(errors);
    }
    return await repository.create(dto);
  }
}

Error Handling in Services

class UserService {
  final UserRepository _repository;
  final Logger _logger;

  UserService(this._repository, this._logger);

  Future<User> getUser(int id) async {
    try {
      final user = await _repository.findById(id);
      if (user == null) {
        throw UserNotFoundException(id);
      }
      return user;
    } catch (e, stackTrace) {
      _logger.error('Failed to get user $id', e, stackTrace);
      rethrow;
    }
  }
}

Module-Level Error Handling

Use onModuleInit to handle initialization errors:
class DatabaseModule extends Module {
  @override
  Future<void> onModuleInit(Locator locator, ModuleContext context) async {
    try {
      final database = locator.get<DatabaseService>();
      await database.connect();
      await database.runMigrations();
    } catch (e) {
      print('Failed to initialize database: $e');
      // Decide: rethrow to prevent app startup, or handle gracefully
      rethrow;
    }
  }
}

Performance Considerations

Lazy Initialization

Use lazy singletons for services that aren’t always needed:
class AppModule extends Module {
  @override
  Future<void> providers(Locator locator) async {
    // Eager: Created during module initialization
    locator.registerSingleton<CriticalService>(
      CriticalService(),
    );
    
    // Lazy: Created only when first accessed
    locator.registerLazySingleton<EmailService>(
      () => EmailService(),
    );
    locator.registerLazySingleton<ReportGenerator>(
      () => ReportGenerator(),
    );
  }
}

Async Service Registration

Handle async initialization properly:
class CoreModule extends Module {
  @override
  Future<void> providers(Locator locator) async {
    // Services that require async initialization
    final prefs = await SharedPreferences.getInstance();
    locator.registerSingleton<SharedPreferences>(prefs);
    
    final database = await openDatabase('app.db');
    locator.registerSingleton<Database>(database);
    
    // Services that depend on async services
    locator.registerSingleton<ConfigService>(
      ConfigService(prefs),
    );
  }
}

Module Import Order

Import order affects initialization performance:
class AppModule extends Module {
  @override
  List<Module> get imports => [
    CoreModule(),      // Initialize first (no dependencies)
    DatabaseModule(),  // Then database (depends on Core)
    UserModule(),      // Then features (depend on Database)
    AuthModule(),      // Features can be parallel
    TodoModule(),
  ];
}
From packages/nest_core/lib/src/container.dart:63-104, modules initialize in dependency-first order:
Future<void> _initializeModuleRecursive(
  Module module,
  Set<Type> visited,
) async {
  // Initialize all imported modules first (dependency-first order)
  for (final importedModule in module.imports) {
    await _initializeModuleRecursive(importedModule, visited);
  }
  
  // Then initialize this module
  await module.onModuleInit(scopedLocator, _context);
}

Lifecycle Management

Proper Initialization

class DatabaseModule extends Module {
  @override
  Future<void> providers(Locator locator) async {
    locator.registerLazySingleton<DatabaseService>(
      () => DatabaseService(),
    );
  }

  @override
  Future<void> onModuleInit(Locator locator, ModuleContext context) async {
    final db = locator.get<DatabaseService>();
    await db.connect();
    await db.runMigrations();
    print('Database initialized successfully');
  }

  @override
  Future<void> onModuleDestroy(Locator locator, ModuleContext context) async {
    final db = locator.get<DatabaseService>();
    await db.close();
    print('Database connection closed');
  }
}

Resource Cleanup

class CacheModule extends Module {
  @override
  Future<void> providers(Locator locator) async {
    locator.registerLazySingleton<CacheService>(
      () => CacheService(),
      dispose: (cache) async {
        await cache.clear();
        print('Cache cleared on disposal');
      },
    );
  }

  @override
  Future<void> onModuleDestroy(Locator locator, ModuleContext context) async {
    // Additional cleanup beyond dispose
    final cache = locator.get<CacheService>();
    await cache.saveToDisk();
  }
}

Dependency Injection Patterns

Constructor Injection

// Good: Clear dependencies in constructor
class UserService {
  final UserRepository _repository;
  final EmailService _emailService;
  final Logger _logger;

  UserService(
    this._repository,
    this._emailService,
    this._logger,
  );

  Future<User> createUser(String email, String name) async {
    final user = await _repository.create(email, name);
    await _emailService.sendWelcomeEmail(user);
    _logger.info('User created: ${user.id}');
    return user;
  }
}

// Module registration
class UserModule extends Module {
  @override
  Future<void> providers(Locator locator) async {
    locator.registerSingleton<UserService>(
      UserService(
        locator.get<UserRepository>(),
        locator.get<EmailService>(),
        locator.get<Logger>(),
      ),
    );
  }
}

Service Locator Pattern (Use Sparingly)

// Acceptable for complex initialization
class ComplexService {
  late final Repository repository;
  late final Cache cache;
  late final Logger logger;

  Future<void> initialize(Locator locator) async {
    repository = locator.get<Repository>();
    cache = locator.get<Cache>();
    logger = locator.get<Logger>();
    
    await cache.load();
  }
}

class ComplexModule extends Module {
  @override
  Future<void> providers(Locator locator) async {
    final service = ComplexService();
    await service.initialize(locator);
    locator.registerSingleton<ComplexService>(service);
  }
}
Prefer constructor injection over service locator pattern for better testability and explicit dependencies.

Testing Best Practices

Test Module Pattern

class TestUserModule extends Module {
  final UserRepository mockRepository;

  TestUserModule(this.mockRepository);

  @override
  Future<void> providers(Locator locator) async {
    locator.registerSingleton<UserRepository>(mockRepository);
    locator.registerFactory<UserService>(
      () => UserService(locator.get<UserRepository>()),
    );
  }

  @override
  List<Type> get exports => [UserService];
}

// In tests
void main() {
  test('user service test', () async {
    final mockRepo = MockUserRepository();
    final container = ApplicationContainer();
    await container.registerModule(TestUserModule(mockRepo));
    
    // Test with mocked repository
    final userService = container.get<UserService>();
    // ...
    
    await container.reset();
  });
}

Summary

  • Module Organization: Feature-based, single responsibility
  • Exports: Only export public APIs, hide implementation details
  • Scoping: Use singletons for stateless, factories for stateful
  • Error Handling: Domain-specific exceptions, proper logging
  • Performance: Lazy initialization, async service registration
  • Lifecycle: Use hooks for initialization and cleanup
  • Testing: Create test modules, always reset container
Follow these patterns consistently across your codebase for maximum maintainability and team productivity.