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:
- A Flutter environment set up
- Safaricom Developer's portal acc
- Basic knowledge of Flutter and Dart
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
- Domain Layer – Defines business logic (PaymentEntity, Use Cases, Repository Interface).
- Data Layer – Handles API calls, implements repository (Mpesa Data Source, Payment Model).
- 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
- Create a new Flutter project
flutter create mpesa_app
cd mpesa_app
- Add required dependencies Open pubspec.yaml and add:
dependencies:
flutter:
sdk: flutter
mpesa_flutter_plugin:
flutter_svg:
provider:
get_it:
Run:
flutter pub get
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
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",
});
}
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,
);
}
}
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);
}
}
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,
);
}
}
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(),
};
}
}
}
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()}');
}
}
}
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;
}
}
}
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,
);
}
},
),
),
],
),
),
),
);
}
);
}
}
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();
}
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(),
),
);
}
}
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
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)