The architecture of an app is the way we organize responsibilities within the project.
When we start in Flutter, the temptation is strong:
“I have a button, and when I press it, I want to download a list of users. So I put the download code inside the button’s
onPressedand that’s it.”
For a quick test it might work, but mixing everything together makes the screen grow quickly. UserScreen can coordinate its visual state, but it shouldn’t know how to connect to the server or interpret its JSON.
Today we are going to learn the single responsibility principle by separating the App into two layers: UI (Interface) and Logic (Services).
The Problem of Mixed Code
Look at this code. It’s what we should NOT do:
// ❌ BAD: Logic mixed with UI
class UserScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ElevatedButton(
child: Text("Load"),
onPressed: () async {
// 😱 HORROR! HTTP requests inside the UI
var url = Uri.parse('https://api.example.com/users');
var response = await http.get(url);
if (response.statusCode == 200) {
// Parse JSON here... more dirty logic...
}
},
);
}
}Why is it bad?
- Not reusable: If you need to load users from another screen, you’ll have to copy and paste the code.
- Hard to read: The Widget file gets filled with technical network code.
- Hard to test: You need to replace or intercept the real calls to test the interface.
Separating Services and Repositories
The solution is to remove data access from the widgets and move it to specialized classes. A service usually wraps a specific API; a repository provides data to the rest of the application and can combine different sources. For this simple example, we’ll use a service.
Imagine a restaurant:
- The Widget is the Waiter: Their job is to smile, show you the menu (UI), and take your order. They don’t cook.
- The Service is the Chef: They are in the kitchen (invisible to the customer). They receive the order, get the ingredients (API), cook them (JSON Parsing), and deliver the finished dish.
Step 1: Create the Service Class
Let’s create a new file, for example lib/services/user_service.dart.
// ✅ GOOD: A dedicated class just for data
class UserService {
// Simulated base URL
final String _baseUrl = 'https://jsonplaceholder.typicode.com';
// Clean method that returns what the UI needs
Future<List<String>> getUsers() async {
// All the "dirty" HTTP logic will go here as we'll see later
// For now, we simulate waiting 2 seconds and returning data
await Future.delayed(Duration(seconds: 2));
return ["Luis", "Ana", "Carlos"];
}
}Notice how clean it is. This class knows nothing about colors, buttons, or BuildContext. It only knows about data.
Step 2: Call the Service from the UI
Now, our Widget goes back to being a simple waiter. It just needs to call the service and wait.
class UserScreen extends StatefulWidget {
@override
_UserScreenState createState() => _UserScreenState();
}
class _UserScreenState extends State<UserScreen> {
// 1. Instantiate the service (our chef)
final UserService _userService = UserService();
List<String> _users = [];
bool _loading = false;
Future<void> _loadData() async {
// Activate the spinner
setState(() => _loading = true);
// 2. Request data from the service (Delegate the dirty work)
final newUsers = await _userService.getUsers();
// Update the UI
if (!mounted) return;
setState(() {
_users = newUsers;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Clean Architecture")),
body: _loading
? Center(child: CircularProgressIndicator()) // Spinner
: ListView.builder(
itemCount: _users.length,
itemBuilder: (ctx, i) => ListTile(title: Text(_users[i])),
),
floatingActionButton: FloatingActionButton(
onPressed: _loadData, // On press, call the function
child: Icon(Icons.download),
),
);
}
}Suggested Folder Structure
To stay organized from now on, I recommend this folder structure under /lib:
/lib/models(Data classes: User, Product…)/screens(The screens: Home, Login…)/widgets(Reusable widgets: Buttons, Cards…)/services(Connection logic: ApiService, AuthService…)
Immediate Advantages
By making this small structural change, we have gained a lot:
- Clean Code: The
buildmethod only worries about whether it’s loading or showing the list. No URLs or HTTP status codes cluttering things up. - Maintainability: If the API URL changes tomorrow, you only need to touch the
user_service.dartfile. You don’t have to search through all your screens. - Scalability: We could add methods like
createUser,deleteUserinsideUserServiceand have it all organized.