DEV Community

Cover image for Why Abstracting Business Logic from the Start Saves You Hours of Refactoring: A Lesson from Swapping GetX to GoRouter
toykam
toykam

Posted on

Why Abstracting Business Logic from the Start Saves You Hours of Refactoring: A Lesson from Swapping GetX to GoRouter

In Software development, navigation is one of the most crucial parts of a user experience. So when I decided to switch my navigation from GetX to GoRouter, I thought it would be a simple swap—until I realized that navigation was directly referenced in every single screen of my app. What could have been a smooth transition turned into a challenge, and I quickly found myself wishing I had abstracted my navigation logic from the very beginning.

This experience taught me a valuable lesson about the power of abstraction in Flutter apps. In this article, I’ll walk you through why abstracting navigation functionalities early on is essential and how it could save you from hours of code refactoring in the future. Let's dive in!


The Problem: Tight Coupling with GetX Navigation

When I first built my app, I used GetX for navigation. It worked well, but all my navigation calls were scattered across individual screens. At the time, it felt natural to do Get.to() directly whenever I needed to move between screens. However, when I decided to transition to GoRouter for its powerful deep-linking capabilities, I realized this choice came at a cost: I had to replace Get.to() calls in every file where navigation happened.

If I had taken the time to abstract my navigation logic into a single service, swapping routing libraries would have been far simpler. But why, exactly, is abstraction so beneficial, and how can we make it work for our navigation needs?


Understanding the Power of Abstraction

Abstraction, in simple terms, is the process of separating implementation details from the main logic. In the case of navigation, it means centralizing routing logic in one place, separate from individual screens. This approach allows us to modify or swap the routing library without touching every screen component directly.

Let’s look at the key benefits:

  • Maintainability: With navigation logic centralized, we only need to make updates in one place, keeping our code more organized and manageable.

  • Flexibility: When it comes to swapping routing solutions, abstraction allows for seamless transitions by updating just the navigation service.

  • Separation of Concerns: Screens stay focused on UI logic, and routing is handled in one centralized service, creating a cleaner codebase.


Implementing a Navigation Service in Flutter

So, how can we apply this in a Flutter app? Let’s start by creating a simple NavigationService that abstracts our routing logic.

Step 1: Define the Abstract Class

Define an abstract class, NavigationService, with essential navigation methods like navigateTo, goBack, and replaceRoute.

abstract class NavigationService {
  void navigateTo(String route, {Map<String, String>? params});
  void goBack();
  void replaceRoute(String route, {Map<String, String>? params});
}
Enter fullscreen mode Exit fullscreen mode

This abstract class will serve as the blueprint, and each router-specific implementation will need to follow it.

Step 2: Implement the NavigationService for Each Router

Here’s an implementation of NavigationService using GoRouter.

GoRouter Implementation

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class GoRouterNavigationService implements NavigationService {
  final GoRouter _router;

  GoRouterNavigationService(this._router);

  @override
  void navigateTo(String route, {Map<String, String>? params}) {
    _router.go(route, extra: params);
  }

  @override
  void goBack() {
    _router.pop();
  }

  @override
  void replaceRoute(String route, {Map<String, String>? params}) {
    _router.pushReplacement(route, extra: params);
  }
}
Enter fullscreen mode Exit fullscreen mode

GetX Implementation

import 'package:get/get.dart';

class GetXNavigationService implements NavigationService {
  @override
  void navigateTo(String route, {Map<String, String>? params}) {
    Get.toNamed(route, arguments: params);
  }

  @override
  void goBack() {
    Get.back();
  }

  @override
  void replaceRoute(String route, {Map<String, String>? params}) {
    Get.offNamed(route, arguments: params);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Set Up Dependency Injection

Now, we introduce Dependency Injection (DI) using the get_it package to decide which navigation service to use. This will allow us to swap between different router libraries (like GoRouter or GetX) without changing the rest of the app’s code.

To get started, first add get_it to your pubspec.yaml:

dependencies:
  get_it: ^7.2.0
Enter fullscreen mode Exit fullscreen mode

Then, set up DI in your app initialization:

import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

void setupNavigationService() {
  // if you want to use GoRouter
  getIt.registerSingleton<NavigationService>(GoRouterNavigationService());
  // if you want to use GetRouter
  getIt.registerSingleton<NavigationService>(GetXNavigationService());
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Initialize DI in main.dart

In your main.dart, call the dependency injection (get_it) initialization function:

void main() {

  setupNavigationService();

  runApp(MyApp());
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Using the NavigationService in Your App

Now, when you need to navigate, you simply retrieve the NavigationService from get_it:

import 'package:get_it/get_it.dart';

class SomeScreen extends StatelessWidget {
  final NavigationService _navigationService = GetIt.instance<NavigationService>();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            _navigationService.navigateTo('/someRoute');
          },
          child: Text('Navigate'),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This way, you don’t have to worry about the routing logic inside your individual screens. Instead, you rely on the abstract NavigationService that points to whichever routing library you're using at the moment.


The Benefits in Action: A Cleaner, More Scalable Codebase

By using DI, I can easily switch between GoRouter and GetX without touching the screens. This keeps the app modular, flexible, and easy to maintain. Want to change the navigation library in the future? Just update the DI configuration.


Conclusion: The Value of Early Abstraction

If I had abstracted navigation from the beginning, I could have easily swapped between routing libraries without major changes. This lesson taught me the importance of abstraction not only for navigation but across all layers of an app. It keeps the code flexible, testable, and maintainable. So, if you’re starting a Flutter app, consider abstracting navigation—and other core features—from the start to save time in the long run.


Now, I’d love to hear from you! Which part of your app’s business logic are you planning to abstract next? Or, if you’ve already implemented abstraction elsewhere in your app, how did you go about it? Drop a comment below and share your experience—I’d love to learn from your approach!

Top comments (0)