DEV Community

Cover image for Integrating Mpesa API in Flutter Using Clean Architecture
joelytic s
joelytic s

Posted on

Integrating Mpesa API in Flutter Using Clean Architecture

Introduction

Money transfer is a crucial service worldwide. Imagine you're on vacation, stranded on an island, and suddenly realize you've run out of cash - with no credit or debit card in sight. A stressful situation, right? This is where mobile money transfer services step in to save the day.

People constantly need to send money - whether it's from a customer to a business (C2B), a business to a customer (B2C), or even between businesses (B2B). In Kenya, M-PESA, a mobile money service by Safaricom, has transformed digital payments, making transactions fast and seamless.

In this guide, we'll learn how to integrate M-PESA STK Push into a Flutter app using the mpesa_flutter_plugin. By following Clean Architecture, we'll ensure the app is scalable, maintainable, and secure for handling payments.

Prerequisites

Before diving into implementation, ensure you have:

Understanding of Clean Architecture principles

Understanding Clean Architecture in M-PESA Flutter Integration
Clean Architecture helps separate concerns, making our M-PESA payment integration scalable, testable, and maintainable.

Key Layers in Our Flutter App

  1. Domain Layer – Defines business logic (PaymentEntity, Use Cases, Repository Interface).
  2. Data Layer – Handles API calls, implements repository (Mpesa Data Source, Payment Model).
  3. Presentation Layer – Manages UI & state (Payment Provider, Payment Screen).

Why It Matters?

  • Easier maintenance & debugging
  • Supports scalability (e.g., adding payment history)
  • Improved testability & flexibility

Now, let's implement these layers step by step.

Project Setup

  1. Create a new Flutter project
flutter create mpesa_app
cd mpesa_app
Enter fullscreen mode Exit fullscreen mode
  1. Add required dependencies Open pubspec.yaml and add:
dependencies:
  flutter:
    sdk: flutter
  mpesa_flutter_plugin: 
  flutter_svg: 
  provider: 
  get_it: 
Enter fullscreen mode Exit fullscreen mode

Run:

flutter pub get
Enter fullscreen mode Exit fullscreen mode

Project Structure

Let's start by setting up our project structure. Here's how we'll organize our files:

mpesa_clean_app/
├── lib/
│   ├── core/                                # Core utilities
│   │   └── di/
│   │       └── injection_container.dart     # Dependency injection setup
│   │
│   ├── domain/                              # Domain Layer (Business Rules)
│   │   ├── entities/                        # Enterprise business rules
│   │   │   └── payment.dart                 # Core payment entity
│   │   ├── repositories/                    # Repository interfaces
│   │   │   └── payment_repository.dart      # Payment repository contract
│   │   └── usecases/                        # Application business rules
│   │       └── process_payment_usecase.dart # Payment processing logic
│   │
│   ├── data/                                # Data Layer (Implementation)
│   │   ├── datasources/                     # Data providers
│   │   │   └── mpesa_data_source.dart       # M-PESA API integration
│   │   ├── models/                          # Data models that extend entities
│   │   │   └── payment_model.dart           # Payment data model
│   │   └── repositories/                    # Repository implementations
│   │       └── payment_repository_impl.dart # Payment repository implementation
│   │
│   ├── presentation/                        # Presentation Layer (UI)
│   │   ├── pages/
│   │   │   └── payment_page.dart            # Payment UI screen
│   │   └── providers/
│   │       └── payment_provider.dart        # Payment state management
│   │
│   └── main.dart                            # Application entry point
│
├── assets/
│   └── pesa.jpg                             # M-PESA logo img
Enter fullscreen mode Exit fullscreen mode

Step-by-Step Implementation

Now, let's implement each part of our application from the ground up, following Clean Architecture principles. We'll start with the innermost layer (Domain) and work our way outward.

1. Domain Layer - The Heart of Our Application

The domain layer contains the core business logic of our application, independent of any external frameworks or implementations.

1.1 Creating the Payment Entity
First, let's define our Payment entity that represents the core business object:

// lib/domain/entities/payment.dart

class Payment {
  final String phoneNumber;
  final double amount;
  final String businessCode;
  final String reference;
  final String description;

  Payment({
    required this.phoneNumber,
    required this.amount,
    this.businessCode = "174379",
    this.reference = "Test Payment",
    this.description = "Test Payment",
  });
}
Enter fullscreen mode Exit fullscreen mode

This entity contains only the essential properties needed for a payment transaction with M-PESA. It has no dependencies on external frameworks or APIs.

1.2 Defining the Repository Interface
Next, we'll define the repository interface that specifies how our application will interact with data sources:

// lib/domain/repositories/payment_repository.dart

import '../entities/payment.dart';

abstract class PaymentRepository {
  // Initiate a payment transaction
  Future<PaymentResult> initiatePayment(Payment payment);
}

// Result class to encapsulate the outcome of a payment operation
class PaymentResult {
  final bool success;
  final String message;
  final Map<String, dynamic>? data;

  PaymentResult({
    required this.success,
    required this.message,
    this.data,
  });

  // Factory constructor for success case
  factory PaymentResult.success(String message, {Map<String, dynamic>? data}) {
    return PaymentResult(
      success: true,
      message: message,
      data: data,
    );
  }

  // Factory constructor for failure case
  factory PaymentResult.failure(String message) {
    return PaymentResult(
      success: false,
      message: message,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice we've included a PaymentResult class to standardize how payment results are communicated throughout our application.

1.3 Implementing the Use Case
Now, let's create a use case that orchestrates the business logic for processing a payment:

// lib/domain/usecases/process_payment_usecase.dart

import '../entities/payment.dart';
import '../repositories/payment_repository.dart';

class ProcessPaymentUseCase {
  final PaymentRepository repository;

  ProcessPaymentUseCase(this.repository);

  // Execute the use case with the given parameters
  Future<PaymentResult> execute({
    required String phoneNumber,
    required String amountString,
  }) async {
    // Validate input
    if (phoneNumber.isEmpty || amountString.isEmpty) {
      return PaymentResult.failure("Phone number and amount are required");
    }

    if (!phoneNumber.startsWith('254') || phoneNumber.length != 12) {
      return PaymentResult.failure("Please enter a valid phone number starting with 254");
    }

    double? amount = double.tryParse(amountString);
    if (amount == null || amount <= 0) {
      return PaymentResult.failure("Please enter a valid amount greater than 0");
    }

    // Create payment entity and process it through the repository
    final payment = Payment(
      phoneNumber: phoneNumber,
      amount: amount,
    );

    return await repository.initiatePayment(payment);
  }
}
Enter fullscreen mode Exit fullscreen mode

This use case contains validation logic and coordinates between the UI and data layers. It depends only on domain entities and interfaces, not on concrete implementations.

2. Data Layer - Connecting to the Outside World

The data layer implements the repository interfaces defined in the domain layer and handles external data sources.

2.1 Creating the Payment Model
First, let's create a model that extends our domain entity:

// lib/data/models/payment_model.dart

import '../../domain/entities/payment.dart';

class PaymentModel extends Payment {
  PaymentModel({
    required String phoneNumber,
    required double amount,
    String businessCode = "174379",
    String reference = "Test Payment",
    String description = "Test Payment",
  }) : super(
          phoneNumber: phoneNumber,
          amount: amount,
          businessCode: businessCode,
          reference: reference,
          description: description,
        );

  // Convert model to map for API requests
  Map<String, dynamic> toMap() {
    return {
      'phoneNumber': phoneNumber,
      'amount': amount,
      'businessShortCode': businessCode,
      'accountReference': reference,
      'transactionDesc': description,
    };
  }

  // Create model from map (e.g., from API responses)
  factory PaymentModel.fromMap(Map<String, dynamic> map) {
    return PaymentModel(
      phoneNumber: map['phoneNumber'],
      amount: map['amount'],
      businessCode: map['businessShortCode'] ?? "174379",
      reference: map['accountReference'] ?? "Test Payment",
      description: map['transactionDesc'] ?? "Test Payment",
    );
  }

  // Create model from domain entity
  factory PaymentModel.fromEntity(Payment payment) {
    return PaymentModel(
      phoneNumber: payment.phoneNumber,
      amount: payment.amount,
      businessCode: payment.businessCode,
      reference: payment.reference,
      description: payment.description,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This model extends our Payment entity with additional functionality for data transformation.

2.2 Implementing the M-PESA Data Source
Now, let's create a data source that handles the actual API calls to M-PESA:

// lib/data/datasources/mpesa_data_source.dart

import 'package:flutter/material.dart';
import 'package:mpesa_flutter_plugin/initializer.dart';
import 'package:mpesa_flutter_plugin/payment_enums.dart';

import '../models/payment_model.dart';

abstract class MpesaDataSource {
  Future<Map<String, dynamic>> initiateSTKPush(PaymentModel payment);
}

class MpesaDataSourceImpl implements MpesaDataSource {
  // Initialize M-PESA SDK
  void initialize() {
    MpesaFlutterPlugin.setConsumerKey(
        "input ur consumer key here");
    MpesaFlutterPlugin.setConsumerSecret(
        "input ur consumer secret key here");
  }

  @override
  Future<Map<String, dynamic>> initiateSTKPush(PaymentModel payment) async {
    try {
      final result = await MpesaFlutterPlugin.initializeMpesaSTKPush(
          businessShortCode: payment.businessCode,
          transactionType: TransactionType.CustomerPayBillOnline,
          amount: payment.amount,
          partyA: payment.phoneNumber,
          partyB: payment.businessCode,
          callBackURL: Uri.parse(
              "https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest"),
          accountReference: payment.reference,
          phoneNumber: payment.phoneNumber,
          baseUri: Uri.parse(
              "https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest"),
          transactionDesc: payment.description,
          passKey:
              "input ur passkey here");

      debugPrint("Transaction Result: $result");
      return {
        'success': true,
        'message': 'STK Push sent successfully',
        'data': result,
      };
    } catch (e) {
      debugPrint("Exception: $e");
      return {
        'success': false,
        'message': e.toString(),
      };
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This class uses the mpesa_flutter_plugin to interact with the M-PESA API. It's responsible for the actual implementation details of the API calls.

2.3 Implementing the Repository
Now, let's implement the repository interface we defined in the domain layer:

// lib/data/repositories/payment_repository_impl.dart

import '../../domain/entities/payment.dart';
import '../../domain/repositories/payment_repository.dart';
import '../datasources/mpesa_data_source.dart';
import '../models/payment_model.dart';

class PaymentRepositoryImpl implements PaymentRepository {
  final MpesaDataSource dataSource;

  PaymentRepositoryImpl(this.dataSource);

  @override
  Future<PaymentResult> initiatePayment(Payment payment) async {
    try {
      // Convert domain entity to data model
      final paymentModel = PaymentModel.fromEntity(payment);

      // Call data source
      final result = await dataSource.initiateSTKPush(paymentModel);

      if (result['success']) {
        return PaymentResult.success(
          'Please check your phone for the STK push prompt',
          data: result['data'],
        );
      } else {
        return PaymentResult.failure(result['message']);
      }
    } catch (e) {
      return PaymentResult.failure('An error occurred: ${e.toString()}');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This repository implementation coordinates between the data source and the domain layer, converting between domain entities and data models.

3. Presentation Layer - Creating the User Interface

The presentation layer handles UI components and state management.

3.1 Implementing the Payment Provider
Let's create a provider for managing payment state in the UI:

// lib/presentation/providers/payment_provider.dart

import 'package:flutter/material.dart';
import '../../domain/usecases/process_payment_usecase.dart';

class PaymentProvider extends ChangeNotifier {
  final ProcessPaymentUseCase _processPaymentUseCase;

  PaymentProvider(this._processPaymentUseCase);

  bool _isLoading = false;
  String? _errorMessage;
  String? _successMessage;

  // Getters
  bool get isLoading => _isLoading;
  String? get errorMessage => _errorMessage;
  String? get successMessage => _successMessage;

  // Reset state
  void resetState() {
    _errorMessage = null;
    _successMessage = null;
    notifyListeners();
  }

  // Process payment
  Future<bool> processPayment({
    required String phoneNumber,
    required String amount,
  }) async {
    resetState();

    _isLoading = true;
    notifyListeners();

    try {
      // Execute the use case
      final result = await _processPaymentUseCase.execute(
        phoneNumber: phoneNumber,
        amountString: amount,
      );

      _isLoading = false;

      if (result.success) {
        _successMessage = result.message;
        notifyListeners();
        return true;
      } else {
        _errorMessage = result.message;
        notifyListeners();
        return false;
      }
    } catch (e) {
      _isLoading = false;
      _errorMessage = "Unexpected error: ${e.toString()}";
      notifyListeners();
      return false;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This provider manages the state of our payment form and communicates with the domain layer through the use case.

3.2 Building the Payment Page
Let's create the UI component that displays the payment form:

// lib/presentation/pages/payment_page.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/payment_provider.dart';

class PaymentPage extends StatefulWidget {
  const PaymentPage({Key? key}) : super(key: key);

  @override
  State<PaymentPage> createState() => _PaymentPageState();
}

class _PaymentPageState extends State<PaymentPage> {
  late TextEditingController _phoneController;
  late TextEditingController _amountController;
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();

  @override
  void initState() {
    super.initState();
    _phoneController = TextEditingController();
    _amountController = TextEditingController();
  }

  @override
  void dispose() {
    _phoneController.dispose();
    _amountController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Consumer<PaymentProvider>(
      builder: (context, provider, child) {
        // Listen for messages from the provider
        WidgetsBinding.instance.addPostFrameCallback((_) {
          if (provider.errorMessage != null) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content: Text(provider.errorMessage!),
                backgroundColor: Colors.red,
                duration: const Duration(seconds: 5),
              ),
            );
            provider.resetState();
          }

          if (provider.successMessage != null) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content: Text(provider.successMessage!),
                backgroundColor: Colors.green,
                duration: const Duration(seconds: 5),
              ),
            );
            provider.resetState();
          }
        });

        return Scaffold(
          appBar: AppBar(
            title: const Text(
              'M-PESA Payment',
              style: TextStyle(color: Colors.white),
            ),
            centerTitle: true,
          ),
          body: SingleChildScrollView(
            padding: const EdgeInsets.all(16.0),
            child: Form(
              key: _formKey,
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  Image.asset(
                    'assets/pesa.jpg',
                    height: 150,
                    width: 150,
                  ),
                  const SizedBox(height: 30.0),
                  TextFormField(
                    controller: _phoneController,
                    keyboardType: TextInputType.phone,
                    decoration: const InputDecoration(
                      labelText: 'Phone Number',
                      hintText: 'Enter phone no (254XXX)',
                      border: OutlineInputBorder(),
                      prefixIcon: Icon(Icons.phone),
                    ),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Please enter a phone number';
                      }
                      if (!value.startsWith('254') || value.length != 12) {
                        return 'Please enter a valid phone number starting with 254';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(height: 20.0),
                  TextFormField(
                    controller: _amountController,
                    keyboardType: TextInputType.number,
                    decoration: const InputDecoration(
                      labelText: 'Amount (KES)',
                      hintText: 'Enter amount',
                      border: OutlineInputBorder(),
                      prefixIcon: Icon(Icons.money),
                    ),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Please enter an amount';
                      }
                      if (double.tryParse(value) == null) {
                        return 'Please enter a valid amount';
                      }
                      if (double.parse(value) <= 0) {
                        return 'Amount must be greater than 0';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(height: 30.0),
                  SizedBox(
                    width: double.infinity,
                    child: ElevatedButton.icon(
                      style: ElevatedButton.styleFrom(
                        padding: const EdgeInsets.symmetric(
                            horizontal: 40, vertical: 15),
                        shape: RoundedRectangleBorder(
                          borderRadius: BorderRadius.circular(30),
                        ),
                      ),
                      icon: provider.isLoading
                          ? const SizedBox(
                              width: 20,
                              height: 20,
                              child: CircularProgressIndicator(
                                color: Colors.white,
                                strokeWidth: 2,
                              ),
                            )
                          : const Icon(Icons.payment),
                      label: Text(
                        provider.isLoading ? 'Processing...' : 'Pay Now',
                        style: const TextStyle(fontSize: 18),
                      ),
                      onPressed: provider.isLoading
                          ? null
                          : () {
                              if (_formKey.currentState!.validate()) {
                                provider.processPayment(
                                  phoneNumber: _phoneController.text,
                                  amount: _amountController.text,
                                );
                              }
                            },
                    ),
                  ),
                ],
              ),
            ),
          ),
        );
      }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This page displays a form for entering payment details and shows loading, error, and success states.

4. Core Layer - Setting Up Dependency Injection

Finally, let's set up dependency injection to wire everything together:

// lib/core/di/injection_container.dart

import 'package:get_it/get_it.dart';

import '../../data/datasources/mpesa_data_source.dart';
import '../../data/repositories/payment_repository_impl.dart';
import '../../domain/repositories/payment_repository.dart';
import '../../domain/usecases/process_payment_usecase.dart';
import '../../presentation/providers/payment_provider.dart';

final sl = GetIt.instance;

void init() {
  // Presentation layer
  sl.registerFactory(
    () => PaymentProvider(sl()),
  );

  // Domain layer - Use cases
  sl.registerLazySingleton(() => ProcessPaymentUseCase(sl()));

  // Domain layer - Repositories
  sl.registerLazySingleton<PaymentRepository>(
    () => PaymentRepositoryImpl(sl()),
  );

  // Data layer - Data sources
  sl.registerLazySingleton<MpesaDataSource>(
    () => MpesaDataSourceImpl(),
  );

  // Initialize M-PESA
  final mpesaDataSource = sl<MpesaDataSource>() as MpesaDataSourceImpl;
  mpesaDataSource.initialize();
}
Enter fullscreen mode Exit fullscreen mode

We're using the get_it package for service location, registering our dependencies in a way that follows our clean architecture layers.

5. Application Entry Point

Finally, let's tie everything together in our main.dart file:

// lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
import 'core/di/injection_container.dart' as di;
import 'presentation/pages/payment_page.dart';
import 'presentation/providers/payment_provider.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  // Enable performance profiling if needed
  debugProfileBuildsEnabled = true;
  debugProfilePaintsEnabled = true;

  // Initialize dependency injection
  di.init();

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(
          create: (_) => di.sl<PaymentProvider>(),
        ),
      ],
      child: MaterialApp(
        title: 'M-PESA Payment',
        theme: ThemeData(
          primarySwatch: Colors.green,
        ),
        home: const PaymentPage(),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This sets up our application, initializes dependencies, and provides our state management to the widget tree.

Now, let's test our application. Open the terminal and run the following command to verify that prompts are being sent correctly and responses are processed as expected.

flutter run
Enter fullscreen mode Exit fullscreen mode

Conclusion

Building an M-PESA payment application using Clean Architecture provides not just a functional app, but a solid foundation for future development. The separation of concerns allows for easier testing, maintenance, and evolution of the codebase.
By following this tutorial, you've learned how to structure a Flutter application with Clean Architecture principles, implement M-PESA integration, and create a seamless payment experience for your users.

Remember that good architecture isn't about following rules blindly, but about creating a codebase that's adaptable to change and easy to understand. As your application grows, the investment in clean architecture will pay dividends in reduced technical debt and increased development speed.

Top comments (0)