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.

Local gate 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.

Local gate dart analyze

Runs 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.

Local gate dart test

Runs 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

SymptomLikely causeFix
missing src/... URIfile has not been created or its name differscompare exports with files on disk
Bad state: Stream has already been listened tomultiple listeners used with a single-subscription controllerchoose broadcast or keep one listener
Stream test never finisheslistener started after the event or controller stayed opensubscribe before save and use tearDown
list changes outside the repositorya mutable reference escapedreturn List.unmodifiable
exception appears after the testa Future was not awaiteduse 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.