Flutter – Stop Passing BuildContext: A Cleaner Approach

The Core Problem: Breaking the Architectural Boundary

Passing BuildContext to a Controller or Service is a leaky abstraction.
You are pulling UI concerns into business logic — and that breaks architectural boundaries.
Here’s why.

1. UI Logic Is Not Business Logic

Problem:
The logic becomes tied to a specific screen.
It can no longer run independently (e.g. from a background task, deep link, or different UI).

Principle:
Logic returns a result.
UI decides how to react.

2. Async + Lifecycle = Crash Risk

Problem:
If the async call completes after the widget is unmounted, using that context causes runtime errors.

Principle:
Keep BuildContext inside the UI layer, where Flutter manages lifecycle safety.

3. BuildContext Destroys Testability

Problem:
A controller that depends on it is no longer pure Dart.
You cannot unit-test it without spinning up Flutter.

Principle:
Business logic must be framework-agnostic and testable in isolation.

Bad Code example

class AuthController {
  final AuthService _authService;

  AuthController(this._authService);

  Future<void> login(
    BuildContext context,
    String email,
    String password,
  ) async {
    try {
      final user = await _authService.login(email, password);

      if (user != null) {
        Navigator.of(context).pushReplacementNamed('/home');
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Login failed')),
        );
      }
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Something went wrong')),
      );
    }
  }
}

Solution: Command Pattern Approach

The base class:

import 'package:flutter/material.dart';

/// Base class for all UI actions triggered by business logic.
abstract class UICommand {
  void execute(BuildContext context);
}

Navigate Command:

class NavigateCommand implements UICommand {
  final String routeName;

  NavigateCommand(this.routeName);

  @override
  void execute(BuildContext context) {
    Navigator.of(context).pushNamed(routeName);
  }
}

Snackbar Command:

class ShowSnackbarCommand implements UICommand {
  final String message;

  ShowSnackbarCommand(this.message);

  @override
  void execute(BuildContext context) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(message)),
    );
  }
}

Controller:

class AuthController {
  final AuthService _authService;

  AuthController(this._authService);

  Future<UICommand?> login(String email, String password) async {
    final user = await _authService.login(email, password);

    if (user == null) {
      return ShowSnackbarCommand("Login failed");
    }

    return NavigateToHomeCommand();
  }
}

UI Layer:

onPressed: () async {
  final command = await controller.login(email, password);

  if (!context.mounted) return;

  command?.execute(context);
}