Skip to main content

Command Palette

Search for a command to run...

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

Updated
β€’7 min read
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.