Dart is not C# with different punctuation
Classes, interfaces, and async/await feel familiar, but idiomatic Dart favors immutable data, named parameters, concise constructors, and explicit nullable types.
final class Product {
const Product({
required this.id,
required this.name,
required this.price,
});
final String id;
final String name;
final double price;
}
final prevents reassignment after initialization. const creates compile-time values when the complete expression is constant. Immutability is a strong default because it limits intermediate states and makes widgets easier to reason about.
Null safety is a contract
String excludes null; String? allows it. Use that distinction to model the domain instead of merely satisfying the compiler.
String greeting(String? displayName) {
final normalized = displayName?.trim();
return normalized == null || normalized.isEmpty
? 'Hello, developer'
: 'Hello, $normalized';
}
Reserve ! for invariants that have already been verified and documented. Type promotion, ?., and ?? usually produce safer code.
Functions and named parameters
Product createProduct({
required String id,
required String name,
double price = 0,
}) {
if (price < 0) {
throw ArgumentError.value(price, 'price', 'cannot be negative');
}
return Product(id: id, name: name.trim(), price: price);
}
Named parameters make Flutter APIs readable. Avoid positional booleans: save(true, false) hides intent while save(validate: true, notify: false) states it.
Abstractions, generics, and extensions
abstract interface class Repository<T> {
Future<List<T>> getAll();
Future<void> save(T value);
}
extension CurrencyFormatting on num {
String toUsd() => '\$${toStringAsFixed(2)}';
}
Interfaces establish contracts, generics preserve type information, and extensions keep domain-adjacent operations out of global utility classes. Keep extensions focused and unsurprising.
Collections and pattern matching
final products = <Product>[
const Product(id: 'p1', name: 'Notebook', price: 1299),
const Product(id: 'p2', name: 'Mouse', price: 49),
];
final premiumNames = products
.where((product) => product.price >= 1000)
.map((product) => product.name)
.toList(growable: false);
Lists preserve order, sets enforce uniqueness, and maps associate keys with values. Choose by behavior, not habit.
String statusMessage(Object state) => switch (state) {
LoadingState() => 'Loading',
SuccessState(:final count) => '$count items loaded',
FailureState(:final message) => 'Failed: $message',
_ => 'Unknown state',
};
Pattern matching is especially useful when a workflow has a closed set of meaningful states.
Future, Stream, and failure
Future<T> delivers one eventual result. Stream<T> emits values over time.
Future<List<Product>> loadProducts(Repository<Product> repository) async {
try {
return await repository.getAll();
} on TimeoutException {
throw const ProductFailure('The request timed out');
} catch (error, stackTrace) {
Error.throwWithStackTrace(
ProductFailure('Products could not be loaded', cause: error),
stackTrace,
);
}
}
Do not catch exceptions only to discard them. Translate technical failures for the caller and preserve causes for diagnosis.
What happens when Dart runs
Every application begins at main(). During development, Dart VM can use JIT compilation to enable fast iteration, debugging, and Hot Reload when integrated with Flutter. In production, supported targets use AOT compilation to generate optimized code before execution. This difference is why measuring performance in debug leads to wrong conclusions.
An isolate owns its memory and event loop. A Future doesn’t automatically create a thread: it represents an asynchronous result processed through the isolate’s flow. Heavy synchronous work still blocks responsiveness. For CPU-intensive work, evaluate another isolate and message-serialization cost; for I/O, asynchronous APIs are usually sufficient.
Guided practice: your first Dart package
In Module 1, Flutter hid some language setup. Now you will use Dart without a UI and build the core of the future Sales Management application. You will start with Product; Customer and Order become independent practice after the pattern is clear.
Checkpoint 1 — create a package, not a loose file
In a study folder outside the Module 1 project, run:
dart create --template=package sales_core
cd sales_core
dart test
The package template creates lib/, test/, pubspec.yaml, and analysis_options.yaml. Its initial test must pass. If dart is not recognized, return to Module 1 diagnostics: Dart SDK ships with Flutter SDK.
Replace lib/sales_core.dart with a public export file:
export 'src/in_memory_product_repository.dart';
export 'src/product.dart';
export 'src/product_repository.dart';
Create lib/src. Consumers import package:sales_core/sales_core.dart; internal files remain organized under src.
Expected result: dart analyze will report missing URIs until all three exported files exist. At this checkpoint, that error is expected and understood.
Checkpoint 2 — model an entity that protects its invariants
Create lib/src/product.dart:
final class Product {
const Product._({
required this.id,
required this.name,
required this.price,
});
factory Product({
required String id,
required String name,
required double price,
}) {
final normalizedId = id.trim();
final normalizedName = name.trim();
if (normalizedId.isEmpty) {
throw ArgumentError.value(id, 'id', 'must not be empty');
}
if (normalizedName.length < 3) {
throw ArgumentError.value(name, 'name', 'must have 3 characters');
}
if (price < 0) {
throw ArgumentError.value(price, 'price', 'must not be negative');
}
return Product._(
id: normalizedId,
name: normalizedName,
price: price,
);
}
final String id;
final String name;
final double price;
Product copyWith({String? name, double? price}) {
return Product(
id: id,
name: name ?? this.name,
price: price ?? this.price,
);
}
}
extension CurrencyFormatting on num {
String toUsd() => '\$${toStringAsFixed(2)}';
}
The public constructor is a factory: it validates and normalizes before calling the private constructor. No valid Product can have an empty ID or negative price, and copyWith goes through the same validation.
Create test/product_test.dart:
import 'package:sales_core/sales_core.dart';
import 'package:test/test.dart';
void main() {
group('Product', () {
test('normalizes text and formats the price', () {
final product = Product(id: ' p1 ', name: ' Mouse ', price: 199.9);
expect(product.id, 'p1');
expect(product.name, 'Mouse');
expect(product.price.toUsd(), r'$199.90');
});
test('rejects a negative price', () {
expect(
() => Product(id: 'p1', name: 'Mouse', price: -1),
throwsArgumentError,
);
});
});
}
Run dart test test/product_test.dart. Protect entity behavior before adding infrastructure.
Checkpoint 3 — define an asynchronous contract
Create lib/src/product_repository.dart:
import 'product.dart';
abstract interface class ProductRepository {
Future<List<Product>> getAll();
Future<void> save(Product product);
Stream<List<Product>> watchAll();
}
The contract describes three behaviors without mentioning a database or HTTP. Future represents one eventual result; Stream represents a sequence of lists over time.
Create lib/src/in_memory_product_repository.dart:
import 'dart:async';
import 'product.dart';
import 'product_repository.dart';
final class InMemoryProductRepository implements ProductRepository {
final List<Product> _items = [];
final StreamController<List<Product>> _changes =
StreamController<List<Product>>.broadcast();
List<Product> get _snapshot => List.unmodifiable(_items);
@override
Future<List<Product>> getAll() async => _snapshot;
@override
Future<void> save(Product product) async {
if (_items.any((item) => item.id == product.id)) {
throw StateError('Product ${product.id} already exists');
}
_items.add(product);
_changes.add(_snapshot);
}
@override
Stream<List<Product>> watchAll() async* {
yield _snapshot;
yield* _changes.stream;
}
Future<void> dispose() => _changes.close();
}
List.unmodifiable prevents a consumer from mutating repository state through a returned reference. The broadcast controller intentionally supports multiple listeners; in production, choose single-subscription versus broadcast deliberately.
Checkpoint 4 — observe an asynchronous change
Create example/main.dart:
import 'package:sales_core/sales_core.dart';
Future<void> main() async {
final repository = InMemoryProductRepository();
final subscription = repository.watchAll().listen((products) {
final names = products.map((product) => product.name).join(', ');
print('Catalog: ${names.isEmpty ? 'empty' : names}');
});
await Future<void>.delayed(Duration.zero);
await repository.save(
Product(id: 'p1', name: 'Notebook', price: 1299),
);
await Future<void>.delayed(Duration.zero);
await subscription.cancel();
await repository.dispose();
}
Run dart run example/main.dart. Output should first contain Catalog: empty, then Catalog: Notebook. await does not create a thread; it lets the isolate continue processing its queue while waiting.
Checkpoint 5 — test the contract, failure, and Stream
Create test/in_memory_product_repository_test.dart:
import 'package:sales_core/sales_core.dart';
import 'package:test/test.dart';
void main() {
late InMemoryProductRepository repository;
setUp(() => repository = InMemoryProductRepository());
tearDown(() => repository.dispose());
test('saves and returns an unmodifiable copy', () async {
final product = Product(id: 'p1', name: 'Mouse', price: 49);
await repository.save(product);
final products = await repository.getAll();
expect(products, [same(product)]);
expect(() => products.clear(), throwsUnsupportedError);
});
test('rejects a duplicate identifier', () async {
await repository.save(Product(id: 'p1', name: 'Mouse', price: 49));
expect(
() => repository.save(Product(id: 'p1', name: 'Keyboard', price: 99)),
throwsStateError,
);
});
test('emits initial state and a change', () async {
final states = repository.watchAll();
final expectation = expectLater(
states,
emitsInOrder([
isEmpty,
predicate<List<Product>>((items) => items.single.id == 'p1'),
]),
);
await Future<void>.delayed(Duration.zero);
await repository.save(Product(id: 'p1', name: 'Mouse', price: 49));
await expectation;
});
}
late promises initialization before use; setUp satisfies that promise for every test. tearDown closes the controller even after a failure. expectLater observes future events.
dart format .Applies the official formatter to files under the current directory. It standardizes presentation; it doesn’t fix design or behavior. Review the diff even after formatting.
In CI, use dart format --output=none --set-exit-if-changed . to check without rewriting. A non-zero exit code should fail the gate.
dart analyzeRuns static analysis from analysis_options.yaml. Read diagnostic severity and code; lints are explicit project decisions, not obstacles to silence indiscriminately.
Static analysis detects whole classes of errors without running the program, but it doesn’t confirm business rules.
dart testRuns tests in the Dart package. Green output means existing assertions passed in that environment; it does not mean every relevant behavior is covered.
Test values, failures, and transitions. Control clock, randomness, and I/O to prevent flaky tests.
Run the complete gate:
dart format .
dart analyze
dart test
The module is complete when all three commands succeed and you can explain which risk each one reduces.
Common errors
| Symptom | Likely cause | Fix |
|---|---|---|
missing src/... URI | file has not been created or its name differs | compare exports with files on disk |
Bad state: Stream has already been listened to | multiple listeners used with a single-subscription controller | choose broadcast or keep one listener |
| Stream test never finishes | listener started after the event or controller stayed open | subscribe before save and use tearDown |
| list changes outside the repository | a mutable reference escaped | return List.unmodifiable |
| exception appears after the test | a Future was not awaited | use await or return the Future |
Independent practice
Add Customer and Order by repeating the process: invariants first, contract second, implementation last. An order must require at least one item and calculate its total from products; do not trust an externally supplied total without validation.
Terms to review: null safety, Future, Stream, isolate, event loop, JIT, AOT, extension, factory, interface, and pattern matching. Use the command and concept reference whenever needed.