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);
}