Day 3: NomadAir Project Scaffold.

Lesson 3 60 min

Lesson 03 — NomadAir Project Scaffold

NomadAir Masterclass · Module 1 · Beginner


The Naive Flutter Approach — And Why It Breaks

Open any beginner Flutter tutorial and the project structure looks like this:

Code
lib/
  main.dart
  screens/
    home_screen.dart
    search_screen.dart
  widgets/
    flight_card.dart
  utils/
    theme.dart

This is a perfectly usable structure for a counter app or a weather widget. At around Lesson 15 of NomadAir — when you have a discovery feed, a flight search form, a seat map, a booking flow, a profile screen, shared state, a design token system, and a mock API layer — it collapses in a specific and painful way.

Any file in lib/ can import any other file in lib/. There is no enforced boundary. A screen can import a utility. A utility can import a widget. A widget can import a repository. The build graph becomes fully connected, and a change in any file can break any other file in non-obvious ways. flutter analyze passes, the app runs, but the architecture has become load-bearing spaghetti.

The failure is not an error message. It is a team-level friction that compounds over time: slow builds because nothing is isolated, merge conflicts because multiple features touch shared files, and test suites that are impossible to run in isolation because every test needs the whole world to exist.

The correct solution is a boundary you enforce at the package level — not the folder level.


What Flutter Is Actually Doing

Flutter's pub package manager resolves dependencies per package, not per file. Each package has its own pubspec.yaml declaring exactly what it depends on. When package nomadair_ui declares a dependency on nomadair_core but not on nomadair_data, Dart's compiler enforces that: any file inside nomadair_ui that attempts to import anything from nomadair_data produces a compile error.

This is not a convention. It is not enforced by a linter rule or a code review. The Dart analyzer and the Dart compiler both reject the import. The boundary is structural, which means it cannot be bypassed by a developer who is in a hurry.

The mechanism that makes this possible is Dart 3.x's class modifier system, combined with path-based package dependencies. A class declared as abstract interface class FlightRepository in packages/core can be implemented by packages/data and referenced by packages/ui — but only because both explicitly list packages/core as a dependency. Neither can reach into the other. That is the architecture this lesson establishes.

Path-based dependencies (using path: ../core in a sub-package's pubspec.yaml) are resolved locally on disk during development. When you eventually publish packages to pub.dev, you switch to version-based dependencies. For a monorepo that you own end-to-end, path dependencies are both faster and more flexible.


The NomadAir Architecture for This Feature

Component Architecture

Lesson 03 — NomadAir Monorepo Architecture Three path-based packages · Dependency graph enforced by pub at compile time nomadair_core abstract final class · abstract interface class · final class Design Tokens AppColors · AppTypography AppSpacing Domain Models FlightModel final class Interfaces FlightRepository abstract interface implements FlightRepository uses AppColors, FlightModel nomadair_ui depends on: nomadair_core Theme NomadAirTheme light() · dark() Components NomadButton NomadCard nomadair_data depends on: nomadair_core (NOT ui) Repositories MockFlightRepository implements FlightRepository const _allFlights = [...] ← compile-time constant app (nomadair_lesson_03) ✕ import blocked by pub Directory packages/core/ packages/ui/ packages/data/ lib/ (app)

NomadAir uses a three-package monorepo alongside the main application:

Code
nomadair_lesson_03/
├── lib/                    ← Main application
└── packages/
    ├── core/               ← Design tokens, domain models, repository interfaces
    ├── ui/                 ← Widget library, NomadAirTheme, component system
    └── data/               ← Repository implementations, mock data layer

The dependency rule is a directed acyclic graph with a strict topological order:

Code
         nomadair_core
         ↑           ↑
   nomadair_ui   nomadair_data
         ↑           ↑
              app

nomadair_core depends on nothing except the Flutter SDK (needed for Color, TextStyle, and other framework types). nomadair_ui depends on nomadair_core and the Flutter SDK. nomadair_data depends on nomadair_core only. The main app depends on all three.

The critical rule: nomadair_ui must never import from nomadair_data, and vice versa. When a widget needs flight data, it receives it through the FlightRepository interface defined in nomadair_core — not by reaching into nomadair_data directly. This is the Dependency Inversion Principle enforced at the package level.


Implementation Deep Dive

Three Dart 3.x class modifiers do the structural work in this lesson:

abstract final class for design tokens.
AppColors, AppTypography, and AppSpacing are declared with both abstract and final modifiers. abstract prevents direct instantiation (AppColors() is a compile error). final prevents subclassing (class MyColors extends AppColors is a compile error). Together, these modifiers communicate exactly what these classes are: shared constants, not polymorphic types.

abstract interface class for repository contracts.
FlightRepository uses both abstract and interface modifiers. interface prevents extension — class FakeRepo extends FlightRepository is a compile error. But class MockFlightRepository implements FlightRepository is valid. This forces every consumer to depend on the interface's shape, not its implementation. You can swap MockFlightRepository for a real API implementation in packages/data without touching packages/ui or the main app.

final class for concrete implementations.
FlightModel and MockFlightRepository are declared final. This prevents accidental subclassing of domain objects — a FlightModel in NomadAir has a precise set of fields defined by the booking domain, and extending it to add UI-layer fields would couple the domain model to the UI, which is exactly the boundary we are trying to enforce.

The barrel export files (core.dart, ui.dart, data.dart) use explicit show clauses to declare exactly what each package exposes publicly. Internal implementation files are reachable only through relative imports within the package. External consumers can only see what the barrel file shows.


Production Readiness

Flowchart

Lesson 03 — Package Resolution Data Flow How a search request flows through the monorepo at runtime APP nomadair_ui nomadair_core nomadair_data main.dart injects MockFlightRepository SearchScreen holds: FlightRepository interface type from core FlightRepository abstract interface class fetchFlights() fetchFlightById() User taps "Search" calls fetchFlights() via interface dispatch to concrete implementation MockFlight Repository filters _allFlights await 350ms async boundary (await) List<FlightModel> typed in nomadair_core setState() rebuild FlightCard Results shown NomadCard × n Key Insight: Why the interface lives in nomadair_core nomadair_ui references FlightRepository (the type) — not MockFlightRepository (the implementation). nomadair_ui therefore has zero dependency on nomadair_data. Swapping MockFlightRepository for a real Dio-backed repo requires zero changes in nomadair_ui. FlightModel (core) ← used by ui (to render cards) AND by data (to populate const list) — zero duplication Single source of truth for the domain type, shared via the core package barrel export.

Three structural invariants to verify after this lesson:

flutter analyze in each package subdirectory exits clean. Run flutter analyze inside packages/core/, packages/ui/, and packages/data/ separately. If a cross-boundary import has crept in, it surfaces here before it compounds.

The dependency graph remains a DAG. If nomadair_core ever tries to import from nomadair_ui or nomadair_data, the graph has a cycle and the build will fail with a circular dependency error. flutter pub get at the root catches this immediately.

Each package builds independently. Running flutter build apk in a workspace that references only one of the sub-packages (for a hypothetical standalone UI component demo app) should succeed without pulling in all three packages. Path dependencies make this possible — version-pinned pub.dev dependencies would not.


Step-by-Step Guide

State Machine

Dart 3.x Class Modifiers in NomadAir Core Three modifier combinations · What each allows and blocks at compile time abstract final class NomadAir examples: AppColors AppTypography AppSpacing ✓ Can: access static members ✕ Cannot: instantiate ✕ Cannot: extend (subclass) ✕ Cannot: implement Pure constant namespace AppColors() ← COMPILE ERROR class X extends AppColors ← ERROR abstract interface class NomadAir examples: FlightRepository (future: HotelRepository) ✓ Can: implement (class X implements Y) ✓ Can: reference as type ✕ Cannot: instantiate ✕ Cannot: extend Define contract, not behaviour ✓ class X implements FlightRepository ✕ class X extends FlightRepository final class NomadAir examples: FlightModel MockFlightRepository NomadButton ✓ Can: instantiate ✓ Can: implement ✕ Cannot: extend (subclass) Concrete, closed implementation ✓ const FlightModel(...) ✓ implements FlightRepository ✕ class X extends FlightModel Modifier Decision Table Use Case Modifier Why Constants (colours, spacing) abstract final class No instance, no subclass — pure namespace Repository contracts abstract interface class Implement-only — no inherited state Domain models, widgets final class Instantiable but sealed — no subclass drift (all three are Dart 3.x additions)

Prerequisites

  • Lesson 01 complete (Flutter SDK verified on Windows, AVD operational)

  • Android Studio with Pixel 7 API 34 AVD running

  • Python 3.11+ in PATH

Execution

Code
python generate_lesson_03.py --generate

The script calls flutter create, creates packages/core/, packages/ui/, and packages/data/ with full implementations, rewrites pubspec.yaml with path dependencies, and runs flutter pub get at the root.

Open nomadair_lesson_03/ in Android Studio. Let Gradle sync. Then:

Code
python generate_lesson_03.py --run

Android Verification

The app opens to the Package Explorer — a three-tab screen:

  • Architecture tab: A live dependency graph drawn with CustomPainter showing four nodes (core, ui, data, app) and their directional dependency arrows.

  • Packages tab: Three expandable cards — one per package — listing the exact classes each package exports.

  • Verify tab: Tap Verify All Packages. Five sequential checks run:

  1. nomadair_core — reads AppColors.brandPrimary and confirms its exact hex value

  2. nomadair_ui — calls NomadAirTheme.light() and confirms Material 3 is active

  3. nomadair_data — instantiates MockFlightRepository, queries BOM → DEL, and shows the returned flights

  4. Boundary check: confirms MockFlightRepository satisfies the FlightRepository interface from core

  5. Boundary check: confirms the violation rule (ui → data import attempt) is blocked at compile time

All five should show green checkmarks. The log panel shows the actual flight objects returned by MockFlightRepository, confirming real code in packages/data is executing.

Tap Inject Violation to observe the graph highlight the illegal ui → data arrow in red with a fix annotation.

iOS Verification (Optional — macOS only)

Code
python generate_lesson_03.py --ios

All five checks pass identically on the iOS Simulator.


Homework — Production Challenge

The current FlightRepository interface declares fetchFlights with three parameters: origin, destination, and departureDate. Add a fourth method to the interface in packages/core: Future<List<String>> fetchAirports() — returning a list of IATA airport codes. Implement it in MockFlightRepository in packages/data returning at least six codes (BOM, DEL, BLR, DXB, LHR, SIN). Do not touch packages/ui or the main app during this extension. Confirm that flutter analyze passes in all three packages independently after the change.

Questions & Discussion

Leave a Reply

Your email address will not be published. Required fields are marked *

System Design Fundamentals – E-Book

Free download

Free eBook: System Design Fundamentals

Create a free account and download the ebook instantly. Learn the core building blocks — scaling, caching, databases and messaging — the way interviewers expect you to explain them.

Register free & download →

Already a member? Sign in to download · See what’s inside