Skip to content
AgileLogics
7 min read

Navigation and App Architecture

Prerequisites: State management

As an app grows past a few screens, you need a clear way to move between screens and a structure that keeps code testable.

Navigation

The simplest navigation pushes a new screen onto a stack:

Navigator.of(context).push(
  MaterialPageRoute(builder: (_) => const DetailPage(id: 42)),
);
// ...and on the detail page:
Navigator.of(context).pop();

Most apps use the go_router package, which maps URLs to screens. That supports deep links (opening the app at a specific screen) and proper browser URLs on the web:

final router = GoRouter(
  routes: [
    GoRoute(path: '/', builder: (context, state) => const HomePage()),
    GoRoute(
      path: '/articles/:id',
      builder: (context, state) => ArticlePage(id: state.pathParameters['id']!),
    ),
  ],
);

// In MaterialApp.router(routerConfig: router), then anywhere:
context.go('/articles/42');

A layered architecture

Flutter's official architecture guide recommends separating an app into layers:

UI layer:    Views (widgets)  ◀──▶  ViewModels (ChangeNotifier / Riverpod / Bloc)
                                          │
Data layer:                         Repositories  ──▶  Services (HTTP, database)
  • Views display state and forward user actions. No business logic.
  • ViewModels hold a screen's state and logic.
  • Repositories are the single source of truth for a type of data, deciding whether it comes from the network, a cache, or local storage.
  • Services wrap external APIs and plugins.

A feature, end to end

class Article {
  const Article(this.id, this.title);
  final int id;
  final String title;
}

abstract interface class ArticleRepository {
  Future<List<Article>> latest();
}

class ArticlesViewModel extends ChangeNotifier {
  ArticlesViewModel(this._repository);

  final ArticleRepository _repository;

  List<Article> articles = [];
  bool loading = false;
  String? error;

  Future<void> load() async {
    loading = true;
    error = null;
    notifyListeners();
    try {
      articles = await _repository.latest();
    } catch (e) {
      error = "Couldn't load articles.";
    } finally {
      loading = false;
      notifyListeners();
    }
  }
}
  • The ViewModel depends on the ArticleRepository interface. The real app passes an implementation that calls the API; tests pass a fake.
  • The widget simply listens to the ViewModel and shows a spinner, the list, or the error.

Passing dependencies in through constructors like this is dependency injection. Packages such as provider, Riverpod, or get_it help create and supply these objects.

Keep it proportional

A small app doesn't need every layer on day one. Start with a model and a repository; add structure as screens, features, and team size grow.