flutter
/

GetX with REST API: Clean Architecture & Best Practices

Last Sync: Today

On this page

14
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

GetX with REST API: Clean Architecture & Best Practices

Introduction

A well‑structured API client is essential for any Flutter app that communicates with a backend. GetX provides the tools to build a clean, maintainable REST API layer: dependency injection, reactive state, and lifecycle management. This guide covers how to design an API client using the repository pattern, integrate it with GetX controllers, handle errors, and add interceptors for authentication and logging.

  1. Choosing an HTTP Client

You can use the built‑in http package for simple needs, or dio for advanced features (interceptors, request cancellation, form data). This guide uses dio because it’s more flexible for production apps. Add the dependencies to your pubspec.yaml:

YAMLRead-only
1
dependencies:
  dio: ^5.4.0
  get: ^4.6.6

  1. Defining Data Models

Create model classes for your API responses. Use json_serializable or manual fromJson/toJson methods. Example:

DARTRead-only
1
class Post {
  final int id;
  final String title;
  final String body;

  Post({required this.id, required this.title, required this.body});

  factory Post.fromJson(Map<String, dynamic> json) {
    return Post(
      id: json['id'],
      title: json['title'],
      body: json['body'],
    );
  }

  Map<String, dynamic> toJson() => {
    'id': id,
    'title': title,
    'body': body,
  };
}

  1. Creating the API Client Service

Encapsulate Dio setup in a service that extends GetxService. This service will be injected into repositories and can be made permanent. Add interceptors for logging, authentication, etc.

DARTRead-only
1
import 'package:dio/dio.dart';
import 'package:get/get.dart';

class ApiService extends GetxService {
  late Dio _dio;

  @override
  void onInit() {
    super.onInit();
    _dio = Dio(BaseOptions(
      baseUrl: 'https://jsonplaceholder.typicode.com',
      connectTimeout: Duration(seconds: 30),
      receiveTimeout: Duration(seconds: 30),
      headers: {'Content-Type': 'application/json'},
    ));

    // Add interceptors
    _dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) {
        // Add auth token if available
        final token = Get.find<AuthService>().token;
        if (token != null) {
          options.headers['Authorization'] = 'Bearer $token';
        }
        return handler.next(options);
      },
      onResponse: (response, handler) => handler.next(response),
      onError: (error, handler) => handler.next(error),
    ));
  }

  Future<Response> get(String path, {Map<String, dynamic>? queryParams}) async {
    try {
      return await _dio.get(path, queryParameters: queryParams);
    } on DioException catch (e) {
      throw _handleError(e);
    }
  }

  Future<Response> post(String path, {dynamic data}) async {
    try {
      return await _dio.post(path, data: data);
    } on DioException catch (e) {
      throw _handleError(e);
    }
  }

  // Add put, delete, etc.

  Exception _handleError(DioException e) {
    if (e.type == DioExceptionType.connectionTimeout) {
      return Exception('Connection timeout');
    } else if (e.type == DioExceptionType.receiveTimeout) {
      return Exception('Receive timeout');
    } else if (e.type == DioExceptionType.cancel) {
      return Exception('Request cancelled');
    } else if (e.response != null) {
      final statusCode = e.response!.statusCode;
      final message = e.response!.data['message'] ?? 'Server error';
      return Exception('$statusCode: $message');
    } else {
      return Exception('Network error: ${e.message}');
    }
  }
}

  1. Repository Pattern

Repositories abstract the data source (API, local database) and provide a clean interface for controllers. They depend on the ApiService and can be injected via GetX DI.

DARTRead-only
1
class PostRepository {
  final ApiService api;
  PostRepository(this.api);

  Future<List<Post>> getPosts() async {
    final response = await api.get('/posts');
    final List data = response.data;
    return data.map((json) => Post.fromJson(json)).toList();
  }

  Future<Post> getPost(int id) async {
    final response = await api.get('/posts/$id');
    return Post.fromJson(response.data);
  }

  Future<Post> createPost(Post post) async {
    final response = await api.post('/posts', data: post.toJson());
    return Post.fromJson(response.data);
  }
}

  1. Controller with Reactive State

The controller uses the repository and exposes reactive state (loading, data, error). Use StateMixin for simplicity.

DARTRead-only
1
class PostController extends GetxController with StateMixin<List<Post>> {
  final PostRepository repository;
  PostController(this.repository);

  @override
  void onInit() {
    super.onInit();
    fetchPosts();
  }

  Future<void> fetchPosts() async {
    change(null, status: RxStatus.loading());
    try {
      final posts = await repository.getPosts();
      if (posts.isEmpty) {
        change(posts, status: RxStatus.empty());
      } else {
        change(posts, status: RxStatus.success());
      }
    } catch (e) {
      change(null, status: RxStatus.error(e.toString()));
    }
  }
}

  1. Dependency Injection with Bindings

Use bindings to register the service, repository, and controller. This ensures lazy loading and proper disposal.

DARTRead-only
1
class PostBinding extends Bindings {
  @override
  void dependencies() {
    Get.lazyPut<ApiService>(() => ApiService(), fenix: true);
    Get.lazyPut<PostRepository>(() => PostRepository(Get.find()));
    Get.lazyPut<PostController>(() => PostController(Get.find()));
  }
}

// In route
GetPage(
  name: '/posts',
  page: () => PostsPage(),
  binding: PostBinding(),
);

  1. UI with Obx/obx

The UI uses the controller's state to show loading, error, or data.

DARTRead-only
1
class PostsPage extends GetView<PostController> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Posts')),
      body: controller.obx(
        (data) => ListView.builder(
          itemCount: data!.length,
          itemBuilder: (_, i) => ListTile(
            title: Text(data[i].title),
            subtitle: Text(data[i].body),
          ),
        ),
        onLoading: Center(child: CircularProgressIndicator()),
        onError: (error) => Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('Error: $error'),
              ElevatedButton(
                onPressed: controller.fetchPosts,
                child: Text('Retry'),
              ),
            ],
          ),
        ),
        onEmpty: Center(child: Text('No posts found')),
      ),
    );
  }
}

  1. Adding Authentication Token

If your API requires authentication, you can store the token (e.g., in GetStorage) and add it to Dio interceptors. Example:

DARTRead-only
1
class AuthInterceptor extends Interceptor {
  final GetStorage storage = GetStorage();

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = storage.read('token');
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }
}

// In ApiService.onInit
_dio.interceptors.add(AuthInterceptor());

  1. Error Handling & Retry

For a more robust solution, you can implement retry logic on certain errors (e.g., network issues, 5xx). Use dio's RetryInterceptor or a custom one.

Best Practices

  • Use a service layer for Dio – Centralizes base URL, timeouts, and interceptors.
  • Implement repository pattern – Keeps API logic separate from controllers, making testing easier.
  • Use StateMixin – Reduces boilerplate for loading/error/success states.
  • Handle errors gracefully – Catch specific exceptions and provide user‑friendly messages.
  • Add interceptors for auth and logging – Keeps request/response handling clean.
  • Test the repository with mocks – Use Mockito to test API calls without making real network requests.
  • Cancel requests when needed – Use CancelToken for long‑running or repetitive requests.

Common Mistakes

  • ❌ Putting API calls directly in controllers – Makes testing hard and mixes concerns. ✅ Use repositories.
  • ❌ Not handling Dio exceptions – May cause crashes or unhelpful errors. ✅ Catch DioException and convert to meaningful messages.
  • ❌ Forgetting to add interceptors for auth – Each request must include the token; centralise it.
  • ❌ Not cancelling streams/subscriptions – Controllers may live beyond the view; cancel in onClose.

FAQ

  • Q: Should I use http or dio?
    A: dio is more feature‑rich (interceptors, cancellation, progress, form data). For simple needs, http is fine, but dio scales better.
  • Q: How to handle token refresh?
    A: You can add an interceptor that checks for 401 responses, refreshes the token, and retries the original request. Use dio's QueueInterceptor or a custom solution.
  • Q: How to test the repository?
    A: Mock the ApiService using Mockito and test that the repository calls the correct endpoints and handles responses correctly.
  • Q: Can I use this architecture with GetxService?
    A: Yes, ApiService extends GetxService to keep it alive across the app. Repositories and controllers are GetxController and are disposed when not needed.
  • Q: How to show a loading indicator for individual API calls?
    A: Use separate reactive flags (e.g., isLoading) or split into smaller controllers. StateMixin already provides a global loading state; for per‑action loading, use additional booleans.

Conclusion

A well‑architected REST API client using GetX gives you clean separation of concerns, testability, and maintainability. By using services, repositories, and controllers, you can build robust networking layers that integrate seamlessly with GetX's reactive state and dependency injection.

Test Your Knowledge

Q1
of 3

Which layer should contain the Dio instance and interceptors?

A
Controller
B
Repository
C
Service (ApiService)
D
View
Q2
of 3

What is the purpose of the repository pattern in this architecture?

A
To hold UI state
B
To abstract the data source and provide a clean interface
C
To manage navigation
D
To store Dio options
Q3
of 3

How do you handle loading and error states for API calls in a controller?

A
Using GetBuilder
B
Using StateMixin or reactive booleans
C
Using FutureBuilder
D
Using StreamBuilder

Previous

getx with firebase

Next

getx session management

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.