How to Apply Layered Architecture (UI, Logic, Data) in Flutter Apps
Layered architecture in Flutter separates your app into UI (widgets/events), Logic (use cases/BLoC), and Data (repositories/services) layers, enabling independent testing and replacement of components.
The flutter/skills repository demonstrates this pattern through its CLI-driven skill-generation tool, where clear boundaries between presentation, domain, and infrastructure concerns allow the same business logic to run identically across command-line and GUI environments. By implementing this three-layer structure in your Flutter applications, you create a codebase where UI changes never break business rules and data sources can be swapped without touching widget code.
What Is Layered Architecture in Flutter?
A layered (or "clean") architecture divides your application into three distinct responsibilities:
- UI / Presentation Layer: Renders widgets, collects user input, and forwards events to business logic using
StatelessWidget,StatefulWidget, or state management solutions likeBlocBuilder - Logic / Domain Layer: Contains pure business rules, coordinates data flow, and exposes clean APIs through use-case classes, BLoC/Cubit implementations, and repository interfaces
- Data / Infrastructure Layer: Accesses external resources (REST APIs, local storage, files) and translates them into plain Dart models via services and data providers
This isolation ensures each layer can be tested independently, replaced (e.g., swapping a network implementation for a mock), and evolved without ripple effects across the codebase.
UI Layer: Handling Presentation and User Input
The UI layer's sole responsibility is parsing input and dispatching commands to the logic layer. In flutter/skills, the CLI entry point serves this role by parsing arguments and forwarding them to domain commands.
// File: tool/generator/bin/skills.dart
import 'package:args/args.dart';
import 'package:skills/tool/generator/lib/src/commands/generate_skill_command.dart';
void main(List<String> arguments) {
final parser = ArgParser()..addFlag('help', abbr: 'h', negatable: false);
final result = parser.parse(arguments);
if (result['help'] as bool) {
print('Usage: skills <command> [options]');
return;
}
// UI layer: dispatch to a command (logic layer)
GenerateSkillCommand().run(result.rest);
}
This pattern translates directly to Flutter UI code. Widgets should only handle ArgParser-like input parsing and instantiate command objects, never performing business logic or direct network calls.
Logic Layer: Implementing Business Rules
Commands and use cases orchestrate data retrieval, validation, and coordination while remaining agnostic to how data is fetched. The GenerateSkillCommand in flutter/skills demonstrates this by processing arguments, delegating network calls to services, and executing pure domain logic.
// File: tool/generator/lib/src/commands/generate_skill_command.dart
class GenerateSkillCommand extends BaseSkillCommand {
@override
Future<void> run(List<String> args) async {
final params = SkillParams.fromArgs(args);
final markdown = await ResourceFetcherService()
.fetchMarkdown(params.sourceUrl); // calls Data layer
final skill = Skill.fromMarkdown(markdown); // pure domain logic
await skill.writeToFile(params.outputPath); // pure domain logic
}
}
Key characteristics of the logic layer:
- Pure Dart implementation (
Skill.fromMarkdown,writeToFile) containing no UI or platform code - Delegation of data fetching to service abstractions (
ResourceFetcherService) - Processing of
SkillParamsmodels that travel between layers as plain objects
Data Layer: Managing External Resources
Services hide implementation details of HTTP requests, file I/O, and external APIs. The UI and Logic layers never call http.get directly; they request data through repository abstractions.
The ResourceFetcherService handles network requests:
// File: tool/generator/lib/src/services/resource_fetcher_service.dart
import 'package:http/http.dart' as http;
class ResourceFetcherService {
Future<String> fetchMarkdown(String url) async {
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw Exception('Failed to fetch resource');
}
return response.body;
}
}
Similarly, the GeminiService encapsulates AI API communication:
// File: tool/generator/lib/src/services/gemini_service.dart
class GeminiService {
final String apiKey; // injected from the environment or config
Future<String> generateSkillDescription(String prompt) async {
// Build request → call Gemini API → parse response
// All of that lives in the Data layer.
}
}
This layer centralizes network error handling, authentication, and serialization, ensuring the rest of your app works with plain String or model objects rather than http.Response instances or raw JSON.
Mapping CLI Patterns to Flutter UI Applications
The same three-layer structure from the flutter/skills CLI tool maps directly to Flutter UI projects:
| UI (Flutter) | Logic (BLoC / ViewModel) | Data (Repository) |
|---|---|---|
SkillPage (a StatelessWidget that displays a list) |
SkillBloc that receives UI events, calls the use-case, and emits states |
SkillRepository that uses ResourceFetcherService and GeminiService underneath |
UI → SkillBloc.add(LoadSkills()) |
SkillBloc → GetSkillsUseCase.execute() |
GetSkillsUseCase → SkillRepository.getAll() |
UI ← BlocBuilder shows SkillLoaded(skills) |
SkillRepository → ResourceFetcherService.fetchMarkdown() |
ResourceFetcherService → HTTP GET |
UI Implementation Example
class SkillPage extends StatelessWidget {
final SkillBloc bloc = SkillBloc();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Skills')),
body: BlocBuilder<SkillBloc, SkillState>(
bloc: bloc,
builder: (_, state) {
if (state is SkillLoading) return const CircularProgressIndicator();
if (state is SkillLoaded) {
return ListView(
children: state.skills.map((s) => ListTile(title: Text(s.title))).toList(),
);
}
return const Text('Tap to load');
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => bloc.add(LoadSkills()),
child: const Icon(Icons.refresh),
),
);
}
}
Logic Implementation Example
class GetSkillsUseCase {
final SkillRepository repository;
GetSkillsUseCase(this.repository);
Future<List<Skill>> call() async {
final markdown = await repository.fetchSkillMarkdown('https://example.com/skill.md');
return SkillParser.fromMarkdown(markdown);
}
}
Data Implementation Example
class SkillRepository {
final ResourceFetcherService fetcher;
SkillRepository(this.fetcher);
Future<String> fetchSkillMarkdown(String url) => fetcher.fetchMarkdown(url);
}
Because each layer has a single responsibility, you can unit-test the logic without any Flutter framework dependencies, mock the data layer with mockito, and run widget tests for the UI in isolation.
Summary
- Separate concerns into UI (widgets/events), Logic (use cases/commands), and Data (services/repositories) layers as demonstrated in
flutter/skills - Keep the UI layer thin by limiting it to input parsing and event dispatching, following the pattern in
tool/generator/bin/skills.dart - Implement business rules in pure Dart classes like
GenerateSkillCommandthat delegate external operations to service abstractions - Encapsulate external I/O in the Data layer using services such as
ResourceFetcherServiceandGeminiServiceto centralize error handling and serialization - Enable testability by ensuring the Logic layer has no Flutter or platform dependencies, allowing unit tests to run without
flutter_test
Frequently Asked Questions
What is the main benefit of layered architecture in Flutter?
Layered architecture allows you to modify, test, and replace individual components without affecting the rest of your application. For example, you can swap the HTTP implementation in ResourceFetcherService without changing any code in GenerateSkillCommand or your Flutter widgets, because the Logic layer depends only on abstract interfaces rather than concrete implementations.
How does the UI layer communicate with the logic layer?
The UI layer dispatches events or command objects to the Logic layer and reacts to state changes or return values. In the flutter/skills CLI, skills.dart instantiates GenerateSkillCommand and calls run(). In a Flutter UI, widgets add events to a BLoC or call use-case methods directly, then rebuild based on emitted states or Future results, keeping presentation concerns separated from business rules.
Can I use layered architecture with Riverpod or Provider instead of BLoC?
Yes. The architecture pattern is agnostic to your state management solution. Whether you use ChangeNotifier with Provider, StateNotifier with Riverpod, or Cubit with flutter_bloc, the key principle remains: your UI layer triggers actions in the Logic layer, which may be implemented as ViewModels, Notifiers, or Use Cases, while the Data layer remains hidden behind repository interfaces like ResourceFetcherService.
Where should network error handling live in a Flutter layered architecture?
Network error handling belongs in the Data layer. Services like ResourceFetcherService catch HTTP exceptions, timeouts, and authentication failures, then translate them into domain-specific exceptions or result objects that the Logic layer can handle uniformly. The Logic layer should never see raw http.Response objects or deal with status codes directly, as implemented in tool/generator/lib/src/services/resource_fetcher_service.dart.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →