Step-by-Step Tutorial: Using GetX MVVM and Dio for API Calls in Flutter

A maintainable project structure is important for scaling a Flutter application. In this article, Iβm sharing a clean and modular folder setup using GetX, Dio, and MVVM-style separation.
This structure works well for apps that have API calls, state management, and modular screens.
π Project Structure
lib
βββ bindings
β βββ user_binding.dart
βββ modules
β βββ controller
β β βββ user_controller.dart
β βββ model
β β βββ user_model.dart
β βββ repository
β β βββ user_repository.dart
β βββ screen
β βββ user_details_screen.dart
β βββ user_screen.dart
βββ network
β βββ api_endpoints.dart
β βββ dio_client.dart
βββ routes
β βββ app_pages.dart
β βββ app_routes.dart
βββ main.dart
π§± Architecture Flow Explanation
1οΈβ£ Controller (ViewModel)
Acts as a bridge between the UI and the data layer
Fetches data from the repository
Manages state using GetX reactivity
Updates the UI whenever data changes
2οΈβ£ Repository Layer
Responsible for handling all data operations
Communicates with the network layer through the Dio client
Converts API responses into model objects
Provides clean data to the controller
3οΈβ£ Network Layer
Contains all API endpoint definitions
Provides a centralized Dio base client with:
Logging
Timeout configuration
Common headers
Ensures consistent network communication across the app
4οΈβ£ Bindings
Handles dependency injection using GetX
Ensures that the Controller, Repository, and Dio client are initialized before the screen loads
Keeps the architecture modular and scalable
5οΈβ£ View (Screen)
Contains only UI presentation code
No business logic is written in the UI
Reacts to data updates provided by the controller
1. bindings/
Contains all GetX bindings.
Bindings help inject controllers and repositories when a route is opened.
Example:
import 'package:get/get.dart';
import '../modules/controller/user_controller.dart';
import '../modules/repository/user_repository.dart';
class UserBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<UserRepository>(() => UserRepository());
Get.lazyPut<UserController>(() => UserController(Get.find()));
}
}
2. modules/
Each feature has its own MVC/MVVM separation.
controller/
Holds all GetX controllers responsible for business logic and state.
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher.dart';
import '../model/user_model.dart';
import '../repository/user_repository.dart';
import '../screen/user_details_screen.dart';
class UserController extends GetxController {
final UserRepository repository;
UserController(this.repository);
RxList<UserModel> users = <UserModel>[].obs;
RxBool loading = false.obs;
RxBool moreLoading = false.obs;
int page = 1;
final int limit = 5;
@override
void onInit() {
fetchUserList();
super.onInit();
}
Future<void> fetchUserList() async {
loading(true);
var data = await repository.fetchUsers(page, limit);
users.value = data;
loading(false);
}
Future<void> loadMore() async {
moreLoading(true);
page++;
var data = await repository.fetchUsers(page, limit);
users.addAll(data);
moreLoading(false);
}
Future<void> openWebsite(String website) async {
final Uri url = Uri.parse("https://$website");
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
Get.snackbar("Error", "Could not launch website");
}
}
void openUserDetails(UserModel user) {
Get.to(() => UserDetailsView(user: user));
}
}
model/
Contains model classes used to parse API response objects.
class UserModel {
int id;
String name;
String username;
String email;
Address address;
String phone;
String website;
Company company;
UserModel({
required this.id,
required this.name,
required this.username,
required this.email,
required this.address,
required this.phone,
required this.website,
required this.company,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json["id"],
name: json["name"],
username: json["username"],
email: json["email"],
address: Address.fromJson(json["address"]),
phone: json["phone"],
website: json["website"],
company: Company.fromJson(json["company"]),
);
}
}
class Address {
String street;
String suite;
String city;
String zipcode;
Geo geo;
Address({
required this.street,
required this.suite,
required this.city,
required this.zipcode,
required this.geo,
});
factory Address.fromJson(Map<String, dynamic> json) {
return Address(
street: json["street"],
suite: json["suite"],
city: json["city"],
zipcode: json["zipcode"],
geo: Geo.fromJson(json["geo"]),
);
}
}
class Geo {
String lat;
String lng;
Geo({
required this.lat,
required this.lng,
});
factory Geo.fromJson(Map<String, dynamic> json) {
return Geo(
lat: json["lat"],
lng: json["lng"],
);
}
}
class Company {
String name;
String catchPhrase;
String bs;
Company({
required this.name,
required this.catchPhrase,
required this.bs,
});
factory Company.fromJson(Map<String, dynamic> json) {
return Company(
name: json["name"],
catchPhrase: json["catchPhrase"],
bs: json["bs"],
);
}
}
repository/
Responsible for all API calls through Dio.
import 'package:dio/dio.dart';
import '../../network/api_endpoints.dart';
import '../../network/dio_client.dart';
import '../model/user_model.dart';
class UserRepository {
final Dio _dio = DioClient.getDio();
Future<List<UserModel>> fetchUsers(int page, int limit) async {
final response = await _dio.get(
ApiEndpoints.users,
queryParameters: {
"_page": page,
"_limit": limit,
},
);
return (response.data as List)
.map((e) => UserModel.fromJson(e))
.toList();
}
}
screen/
UI screens that interact with controllers.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controller/user_controller.dart';
class UserView extends StatelessWidget {
UserView({super.key});
final controller = Get.find<UserController>();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF3F4F6), // Soft background
appBar: AppBar(
title: const Text(
"Users",
style: TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
centerTitle: true,
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
),
body: Obx(() {
if (controller.loading.value) {
return const Center(
child: CircularProgressIndicator(color: Colors.deepPurple),
);
}
return NotificationListener<ScrollNotification>(
onNotification: (scroll) {
if (!controller.moreLoading.value &&
scroll.metrics.pixels == scroll.metrics.maxScrollExtent) {
controller.loadMore();
}
return false;
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
itemCount: controller.users.length +
(controller.moreLoading.value ? 1 : 0),
itemBuilder: (context, index) {
if (index >= controller.users.length) {
return const Padding(
padding: EdgeInsets.all(14),
child: Center(
child: CircularProgressIndicator(color: Colors.deepPurple),
),
);
}
final user = controller.users[index];
return GestureDetector(
onTap: () => controller.openUserDetails(user),
child: Container(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(0.06),
blurRadius: 10,
offset: const Offset(2, 2),
)
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Full Name
Text(
user.name,
style: const TextStyle(
color: Colors.black87,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
// Email
Row(
children: [
const Icon(Icons.email_outlined,
color: Colors.black54, size: 18),
const SizedBox(width: 6),
Text(
user.email,
style: const TextStyle(
color: Colors.black54,
fontSize: 14,
),
)
],
),
const SizedBox(height: 8),
// Website Clickable
GestureDetector(
onTap: () => controller.openWebsite(user.website),
child: Row(
children: [
const Icon(Icons.language,
color: Colors.blueAccent, size: 18),
const SizedBox(width: 6),
Text(
user.website,
style: const TextStyle(
color: Colors.blueAccent,
fontSize: 14,
decoration: TextDecoration.underline,
),
)
],
),
),
],
),
),
);
},
),
);
}),
);
}
}
User Details screen
UI screens that interact with controllers.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../model/user_model.dart';
class UserDetailsView extends StatelessWidget {
final UserModel user;
const UserDetailsView({super.key, required this.user});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F6FA),
appBar: AppBar(
title: Text(
user.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios),
onPressed: () => Get.back(),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Container(
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(0.06),
blurRadius: 12,
offset: const Offset(2, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_headerSection(),
const SizedBox(height: 20),
const Divider(),
const SizedBox(height: 10),
_infoRow(Icons.email_outlined, "Email", user.email),
_infoRow(Icons.phone_outlined, "Phone", user.phone),
_infoRow(Icons.language, "Website", user.website),
_infoRow(Icons.business_outlined, "Company", user.company.name),
_infoRow(Icons.location_on_outlined, "Address",
"${user.address.street}, ${user.address.city}, ${user.address.zipcode}"),
],
),
),
),
);
}
// -------------------------
// Main Header: Name + Icon
// -------------------------
Widget _headerSection() {
return Row(
children: [
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(0.15),
shape: BoxShape.circle,
),
child: const Icon(Icons.person, color: Colors.blueAccent, size: 32),
),
const SizedBox(width: 16),
Expanded(
child: Text(
user.name,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
),
),
],
);
}
// -------------------------
// Reusable Info Row Widget
// -------------------------
Widget _infoRow(IconData icon, String label, String value) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: Colors.deepPurple, size: 22),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black54,
),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 15,
color: Colors.black87,
),
),
],
),
),
],
),
);
}
}
3. network/
dio_client.dart
Centralized Dio instance for headers, interceptors, and error handling.
import 'package:dio/dio.dart';
class DioClient {
static Dio getDio() {
Dio dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
));
dio.interceptors.add(LogInterceptor(
request: true,
requestBody: true,
responseBody: true,
));
return dio;
}
}
api_endpoints.dart
Stores all API routes.
class ApiEndpoints {
static const baseUrl = "https://jsonplaceholder.typicode.com";
static const users = "$baseUrl/users";
}
4. routes/
app_routes.dart
Keeps route names in one place.
import 'package:get/get_navigation/src/routes/get_route.dart';
import '../bindings/user_binding.dart';
import '../modules/screen/user_screen.dart';
class AppRoutes {
static const user = "/users";
static List<GetPage> pages = [
GetPage(
name: user,
page: () => UserView(),
binding: UserBinding(),
),
];
}
app_pages.dart
Maps routes with screens and bindings.
import 'package:get/get.dart';
import '../bindings/user_binding.dart';
import '../modules/screen/user_screen.dart';
import 'app_routes.dart';
class AppPages {
static const initial = AppRoutes.user;
static final routes = [
GetPage(
name: AppRoutes.user,
page: () => UserView(),
binding: UserBinding(),
),
];
}
5. main.dart
Bootstraps the app with GetMaterialApp.
import 'package:example/routes/app_pages.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
debugShowCheckedModeBanner: false,
title: "GetX Dio MVVM Demo",
initialRoute: AppPages.initial,
getPages: AppPages.routes,
);
}
}
π¦ pubspec.yaml Dependencies
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
dio: ^5.9.0
get: ^4.7.3
url_launcher: ^6.3.2
dev_dependencies:
flutter_test:
sdk: flutter
π Conclusion
In this article, we explored a clean and scalable Flutter architecture using GetX, Dio, Repository Pattern, and Bindings. By separating responsibilities into modular layersβController, Repository, Network, Bindings, and Viewβwe achieve a structure that is easy to maintain, test, and extend.
This architecture not only improves code readability but also enhances performance and development speed. Whether you're building small apps or large-scale production projects, following this layered approach helps you write cleaner code, reduce bugs, and scale your application with confidence.
If you continue to follow these patterns, your future Flutter projects will become more organized, predictable, and developer-friendly.



