Dart Best Practices: Writing Clean and Maintainable Code
# Dart Best Practices: Writing Clean and Maintainable Code
Consistent conventions are the fastest way to keep a Dart codebase healthy. Over the years, I have refined a set of practices that dramatically reduce bugs, speed up onboarding, and make code reviews a breeze. This guide covers everything from naming conventions to the latest Dart 3+ features.
Naming Conventions
Good naming eliminates the need for most comments. The Dart style guide is opinionated for a reason.
Classes, Enums, and Typedefs
Use `UpperCamelCase` for types. Names should reflect the domain, not the implementation.
class=class="code-string">"code-comment">// Bad
class Mgr {
void doStuff() {}
}
class=class="code-string">"code-comment">// Good
class AuthenticationManager {
void signInWithEmail(String email, String password) {}
}Variables, Parameters, and Functions
Use `lowerCamelCase`. Booleans should read like assertions.
class=class="code-string">"code-comment">// Bad
bool flag = true;
String s = class="code-string">'hello';
int process(int x) => x * class="code-number">2;
class=class="code-string">"code-comment">// Good
bool isAuthenticated = true;
String welcomeMessage = class="code-string">'hello';
int doubleQuantity(int quantity) => quantity * class="code-number">2;Constants and File Names
Use `lowerCamelCase` for constants (not `SCREAMING_CAPS`). File names use `snake_case`.
class=class="code-string">"code-comment">// Bad
const int MAX_RETRY_COUNT = class="code-number">3;
class=class="code-string">"code-comment">// file: AuthenticationManager.dart
class=class="code-string">"code-comment">// Good
const int maxRetryCount = class="code-number">3;
class=class="code-string">"code-comment">// file: authentication_manager.dartPrivate Members
Prefix with underscore. Keep the public API surface as small as possible.
class UserRepository {
final ApiClient _apiClient;
final Cache _cache;
UserRepository(this._apiClient, this._cache);
class=class="code-string">"code-comment">// Only expose what consumers actually need
Future<User> fetchUser(String id) async {
return _cache.get(id) ?? await _fetchAndCache(id);
}
Future<User> _fetchAndCache(String id) async {
final user = await _apiClient.getUser(id);
_cache.set(id, user);
return user;
}
}Null Safety
Dart's sound null safety is one of its greatest strengths. Use it intentionally rather than fighting it.
Avoid Unnecessary Nullables
Every nullable type is a question mark your future self has to answer. Keep the non-nullable surface as large as possible.
class=class="code-string">"code-comment">// Bad - why is this nullable?
class UserProfile {
String? name;
String? email;
int? age;
UserProfile({this.name, this.email, this.age});
}
class=class="code-string">"code-comment">// Good - required fields are non-nullable
class UserProfile {
final String name;
final String email;
final int age;
const UserProfile({
required this.name,
required this.email,
required this.age,
});
}Use `late` Sparingly
`late` is a promise to the compiler that you will initialize a value before it is read. Break that promise and you get a runtime error, which defeats the purpose of null safety.
class=class="code-string">"code-comment">// Dangerous - runtime error if accessed before init
late final DatabaseConnection _db;
class=class="code-string">"code-comment">// Safer - nullable with explicit check
DatabaseConnection? _db;
Future<DatabaseConnection> getDb() async {
return _db ??= await DatabaseConnection.open();
}Null-Aware Operators
Master the full set: `??`, `??=`, `?.`, `?..`, and `!`.
class=class="code-string">"code-comment">// Bad - verbose null checks
String displayName;
if (user.nickname != null) {
displayName = user.nickname!;
} else {
displayName = user.fullName;
}
class=class="code-string">"code-comment">// Good - concise null-aware chain
final displayName = user.nickname ?? user.fullName;
class=class="code-string">"code-comment">// Good - null-aware cascade
canvas?.drawRect(rect)
..fillColor = Colors.blue
..strokeWidth = class="code-number">2.0;Immutable Models
Mutable state is the root of a surprising number of bugs. In my codebases, I enforce immutability for all data models without exception.
class=class="code-string">"code-comment">// Bad - mutable, no equality
class Product {
String name;
double price;
bool inStock;
Product(this.name, this.price, this.inStock);
}
class=class="code-string">"code-comment">// Good - immutable, value equality, easy copying
class Product {
final String name;
final double price;
final bool inStock;
const Product({
required this.name,
required this.price,
required this.inStock,
});
Product copyWith({
String? name,
double? price,
bool? inStock,
}) {
return Product(
name: name ?? this.name,
price: price ?? this.price,
inStock: inStock ?? this.inStock,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Product &&
name == other.name &&
price == other.price &&
inStock == other.inStock;
@override
int get hashCode => Object.hash(name, price, inStock);
}For larger projects, I recommend `freezed` to generate the boilerplate automatically.
Error Handling
Never use exceptions for control flow. Model expected failures explicitly.
Custom Exception Hierarchy
class=class="code-string">"code-comment">// Bad - generic exceptions with string messages
throw Exception(class="code-string">'User not found');
throw Exception(class="code-string">'Network error');
class=class="code-string">"code-comment">// Good - typed exception hierarchy
sealed class AppException implements Exception {
final String message;
final StackTrace? stackTrace;
const AppException(this.message, [this.stackTrace]);
}
class NetworkException extends AppException {
final int? statusCode;
const NetworkException(super.message, {this.statusCode, super.stackTrace});
}
class ValidationException extends AppException {
final Map<String, String> fieldErrors;
const ValidationException(super.message, {required this.fieldErrors});
}
class NotFoundException extends AppException {
final String resourceType;
final String resourceId;
const NotFoundException(this.resourceType, this.resourceId)
: super(class="code-string">'$resourceType with id $resourceId not found');
}Result Type Pattern
For operations that can fail in expected ways, return a result instead of throwing.
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
final T value;
const Success(this.value);
}
class Failure<T> extends Result<T> {
final AppException error;
const Failure(this.error);
}
class=class="code-string">"code-comment">// Usage
Future<Result<User>> fetchUser(String id) async {
try {
final response = await _api.get(class="code-string">'/users/$id');
return Success(User.fromJson(response.data));
} on DioException catch (e) {
return Failure(NetworkException(
class="code-string">'Failed to fetch user',
statusCode: e.response?.statusCode,
));
}
}
class=class="code-string">"code-comment">// Consuming the result
final result = await fetchUser(class="code-string">'class="code-number">123');
switch (result) {
case Success(:final value):
showProfile(value);
case Failure(:final error):
showError(error.message);
}Extension Methods
Extensions keep your models clean while adding domain-specific convenience.
extension StringValidation on String {
bool get isValidEmail =>
RegExp(rclass="code-string">'^[\w-\.]+@([\w-]+\.)+[\w-]{class="code-number">2,class="code-number">4}$').hasMatch(this);
bool get isStrongPassword =>
length >= class="code-number">8 &&
contains(RegExp(rclass="code-string">'[A-Z]')) &&
contains(RegExp(rclass="code-string">'[class="code-number">0-class="code-number">9]')) &&
contains(RegExp(rclass="code-string">'[!@#$%^&*]'));
String get capitalized =>
isEmpty ? this : class="code-string">'${this[class="code-number">0].toUpperCase()}${substring(class="code-number">1)}';
}
extension DateTimeFormatting on DateTime {
String get relativeTime {
final diff = DateTime.now().difference(this);
if (diff.inDays > class="code-number">365) return class="code-string">'${diff.inDays ~/ class="code-number">365}y ago';
if (diff.inDays > class="code-number">30) return class="code-string">'${diff.inDays ~/ class="code-number">30}mo ago';
if (diff.inDays > class="code-number">0) return class="code-string">'${diff.inDays}d ago';
if (diff.inHours > class="code-number">0) return class="code-string">'${diff.inHours}h ago';
if (diff.inMinutes > class="code-number">0) return class="code-string">'${diff.inMinutes}m ago';
return class="code-string">'just now';
}
}
extension IterableExtensions<T> on Iterable<T> {
T? firstWhereOrNull(bool Function(T) test) {
for (final element in this) {
if (test(element)) return element;
}
return null;
}
Map<K, List<T>> groupBy<K>(K Function(T) keyOf) {
final map = <K, List<T>>{};
for (final element in this) {
(map[keyOf(element)] ??= []).add(element);
}
return map;
}
}Sealed Classes and Pattern Matching
Dart 3 introduced sealed classes and exhaustive pattern matching. This is a game changer for state management and domain modeling.
Modeling UI State
sealed class PageState<T> {
const PageState();
}
class Loading<T> extends PageState<T> {
const Loading();
}
class Loaded<T> extends PageState<T> {
final T data;
const Loaded(this.data);
}
class Error<T> extends PageState<T> {
final String message;
final VoidCallback? onRetry;
const Error(this.message, {this.onRetry});
}
class=class="code-string">"code-comment">// Exhaustive switch - compiler warns if you miss a case
Widget buildPage(PageState<List<Product>> state) {
return switch (state) {
Loading() => const CircularProgressIndicator(),
Loaded(:final data) => ProductList(products: data),
Error(:final message, :final onRetry) => ErrorView(
message: message,
onRetry: onRetry,
),
};
}Modeling Domain Logic
sealed class PaymentMethod {
const PaymentMethod();
}
class CreditCard extends PaymentMethod {
final String last4;
final String brand;
const CreditCard({required this.last4, required this.brand});
}
class BankTransfer extends PaymentMethod {
final String iban;
const BankTransfer({required this.iban});
}
class DigitalWallet extends PaymentMethod {
final String provider;
const DigitalWallet({required this.provider});
}
String describePayment(PaymentMethod method) => switch (method) {
CreditCard(:final brand, :final last4) => class="code-string">'$brand ending in $last4',
BankTransfer(:final iban) => class="code-string">'Bank transfer to $iban',
DigitalWallet(:final provider) => class="code-string">'Pay with $provider',
};Dart 3+ Features You Should Be Using
Records
Lightweight, immutable data bundles without the ceremony of a full class.
class=class="code-string">"code-comment">// Before Dart class="code-number">3 - needed a class or returned a List
class Pair<A, B> {
final A first;
final B second;
Pair(this.first, this.second);
}
class=class="code-string">"code-comment">// Dart class="code-number">3 - records
(String, int) parseNameAge(String input) {
final parts = input.split(class="code-string">':');
return (parts[class="code-number">0], int.parse(parts[class="code-number">1]));
}
class=class="code-string">"code-comment">// Named fields for clarity
({String host, int port}) parseAddress(String address) {
final parts = address.split(class="code-string">':');
return (host: parts[class="code-number">0], port: int.parse(parts[class="code-number">1]));
}
class=class="code-string">"code-comment">// Destructuring
final (name, age) = parseNameAge(class="code-string">'Alice:class="code-number">30');
final (:host, :port) = parseAddress(class="code-string">'localhost:class="code-number">8080');If-Case and Switch Expressions
class=class="code-string">"code-comment">// If-case for inline pattern matching
void processResponse(Object response) {
if (response case {class="code-string">'status': class="code-string">'ok', class="code-string">'data': Map data}) {
handleData(data);
}
if (response case [int first, int second, ...]) {
print(class="code-string">'Starts with $first, $second');
}
}
class=class="code-string">"code-comment">// Switch expression - returns a value
String httpStatus(int code) => switch (code) {
class="code-number">200 => class="code-string">'OK',
class="code-number">301 => class="code-string">'Moved Permanently',
class="code-number">404 => class="code-string">'Not Found',
>= class="code-number">500 && < class="code-number">600 => class="code-string">'Server Error',
_ => class="code-string">'Unknown',
};Class Modifiers
class=class="code-string">"code-comment">// interface class - can be implemented but not extended
interface class Authenticator {
Future<bool> authenticate(String token);
}
class=class="code-string">"code-comment">// base class - can be extended but not implemented
base class Repository {
final Database _db;
Repository(this._db);
}
class=class="code-string">"code-comment">// final class - cannot be extended or implemented outside the library
final class AppConfig {
final String apiUrl;
final Duration timeout;
const AppConfig({required this.apiUrl, required this.timeout});
}
class=class="code-string">"code-comment">// mixin class - can be used as both a mixin and a class
mixin class Loggable {
void log(String message) => print(class="code-string">'[${runtimeType}] $message');
}Common Dart Anti-patterns
Anti-pattern: Stringly-Typed Code
class=class="code-string">"code-comment">// Bad - errors only show up at runtime
void setTheme(String theme) {
if (theme == class="code-string">'dark') { class=class="code-string">"code-comment">/* ... */ }
else if (theme == class="code-string">'light') { class=class="code-string">"code-comment">/* ... */ }
}
setTheme(class="code-string">'drak'); class=class="code-string">"code-comment">// typo, no compiler error
class=class="code-string">"code-comment">// Good - type-safe
enum AppTheme { dark, light, system }
void setTheme(AppTheme theme) => switch (theme) {
AppTheme.dark => activateDarkMode(),
AppTheme.light => activateLightMode(),
AppTheme.system => followSystemTheme(),
};Anti-pattern: God Classes
class=class="code-string">"code-comment">// Bad - one class does everything
class UserManager {
Future<User> fetchUser() async { class=class="code-string">"code-comment">/* ... */ }
void validateEmail(String email) { class=class="code-string">"code-comment">/* ... */ }
void sendPushNotification(String message) { class=class="code-string">"code-comment">/* ... */ }
void writeToAnalytics(String event) { class=class="code-string">"code-comment">/* ... */ }
Widget buildProfileCard() { class=class="code-string">"code-comment">/* ... */ }
}
class=class="code-string">"code-comment">// Good - single responsibility
class UserRepository { class=class="code-string">"code-comment">/* fetch, cache */ }
class EmailValidator { class=class="code-string">"code-comment">/* validate */ }
class NotificationService { class=class="code-string">"code-comment">/* push, local */ }
class AnalyticsTracker { class=class="code-string">"code-comment">/* events */ }
class ProfileCard extends StatelessWidget { class=class="code-string">"code-comment">/* UI */ }Anti-pattern: Catching Everything
class=class="code-string">"code-comment">// Bad - swallows all errors silently
try {
await riskyOperation();
} catch (e) {
print(e);
}
class=class="code-string">"code-comment">// Good - catch specific types, rethrow unexpected ones
try {
await riskyOperation();
} on NetworkException catch (e) {
showSnackbar(class="code-string">'Connection failed: ${e.message}');
} on ValidationException catch (e) {
showFieldErrors(e.fieldErrors);
} catch (e, stackTrace) {
class=class="code-string">"code-comment">// Unexpected error - log and rethrow
crashReporter.record(e, stackTrace);
rethrow;
}Anti-pattern: Nested Futures
class=class="code-string">"code-comment">// Bad - callback pyramid
fetchUser(id).then((user) {
fetchOrders(user.id).then((orders) {
fetchDetails(orders.first.id).then((details) {
updateUI(details);
});
});
});
class=class="code-string">"code-comment">// Good - flat async/await
final user = await fetchUser(id);
final orders = await fetchOrders(user.id);
final details = await fetchDetails(orders.first.id);
updateUI(details);Lint Configuration
In my codebases, I enforce these rules from day one. A strict `analysis_options.yaml` catches problems before they reach code review.
include: package:flutter_lints/flutter.yaml
analyzer:
errors:
missing_return: error
dead_code: warning
unused_import: warning
language:
strict-casts: true
strict-raw-types: true
linter:
rules:
# Style
- always_declare_return_types
- annotate_overrides
- avoid_print
- prefer_const_constructors
- prefer_const_declarations
- prefer_final_fields
- prefer_final_locals
- sort_constructors_first
# Safety
- avoid_dynamic_calls
- avoid_returning_null_for_future
- cancel_subscriptions
- close_sinks
- literal_only_boolean_expressions
- no_adjacent_strings_in_list
- throw_in_finally
- unnecessary_statements
- avoid_slow_async_io
# Readability
- prefer_single_quotes
- require_trailing_commas
- use_named_constants
- unnecessary_lambdas
- prefer_expression_function_bodiesI also recommend running `dart fix --apply` regularly to auto-fix trivial lint issues across the entire project.
Conclusion
Clean Dart code reduces onboarding time and long-term maintenance costs. The practices here are not theoretical ideals; they are patterns I apply in every project. Start with strict linting, enforce immutability, embrace sealed classes for domain modeling, and lean into pattern matching wherever you can. Your future self and your teammates will thank you.
I can help you define a team-wide Dart quality checklist and set up automated enforcement in CI.
Related Articles
Flutter State Management: Riverpod, Provider, and Bloc Comparison
Compare state management approaches in Flutter. Understand Riverpod, Provider, and Bloc with clear decision criteria for each scenario.
Clean Architecture in Flutter: Building Scalable Applications
Learn how to apply Clean Architecture in Flutter pragmatically. A practical guide to layers, dependency management, and testable code.
Flutter Performance Optimization: Complete Guide
Improve your Flutter app performance systematically. Learn rebuild optimization, memory management, lazy loading, and profiling techniques.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch