DEV Community

Cover image for Design Patterns in Flutter: Building "BBB - Ba Ba Bank" Application
Pablo Discobar
Pablo Discobar

Posted on

Design Patterns in Flutter: Building "BBB - Ba Ba Bank" Application

Table of Contents

  1. Introduction
  2. Creational Patterns
  3. Structural Patterns
  4. Behavioral Patterns
  5. Architectural Patterns
  6. When to Use Design Patterns
  7. Conclusion
  8. Additional Resources

Introduction

Welcome, Flutter fans, to the world of "BBB - Ba Ba Bank" - the most imaginary bank you'll ever code for! πŸ‘πŸ’°

Disclaimer: This is a work of fiction. Any resemblance to actual banks, solvent or bankrupt, is purely coincidental. All banking functions depicted are used solely for educational purposes in learning Flutter and Dart design patterns. No real money was lost or gained in the making of this guide.

Imagine you're tasked with creating a mobile app for this quirky bank. Your mission: build an app that's strong, can grow, and is easy to fix. It should handle everything from counting sheep (not coins) to giving wool-based credit scores. As we go on this fluffy journey, we'll face challenges that we can solve with design patterns - the handy tools in a developer's toolbox.

Design patterns are like cooking recipes, but for coding. They help solve common problems that pop up in programming. By learning these patterns, we can write code that's faster, more flexible, and easier to manage than a well-behaved sheep.

In this guide, we'll see how to use different design patterns as we build our "Ba Ba Bank" app. We'll look at four main types of patterns:

  1. Creational Patterns: These help us make new things in our code, like minting new sheep... I mean, coins.
  2. Structural Patterns: These help us organize our code, like herding sheep into pens.
  3. Behavioral Patterns: These help different parts of our code work together, making sure our sheep (code parts) play nice.
  4. Architectural Patterns: These give our whole app structure, like planning the fanciest sheep barn ever.

So, grab your coding tools, put on your developer hat, and let's explore Flutter design patterns with "Ba Ba Bank"! Remember, in this made-up bank, the only real thing we're saving is knowledge. Let's make it grow! πŸš€πŸ‘πŸ’»

Creational Patterns

Creational patterns focus on object creation mechanisms, trying to create objects in a manner suitable to the situation. As we start developing "Ba Ba Bank", we'll encounter several scenarios where these patterns prove invaluable.

Factory Method

Image description

As we begin implementing the account creation system for "Ba Ba Bank", we realize that we need a flexible way to create different types of bank accounts. We anticipate supporting various account types such as savings accounts, checking accounts, and possibly investment accounts in the future. This is where the Factory Method pattern comes in handy.

The Factory Method pattern provides an interface for creating objects in a superclass, allowing subclasses to alter the type of objects that will be created. This pattern is particularly useful for our bank account creation system because it allows us to add new account types in the future without modifying existing code.

Let's implement the Factory Method pattern for our bank account creation:

abstract class BankAccount {
  String accountNumber;
  double balance;

  BankAccount(this.accountNumber, this.balance);

  void deposit(double amount);
  bool withdraw(double amount);
  double getBalance();
}

class SavingsAccount extends BankAccount {
  double interestRate;

  SavingsAccount(String accountNumber, double balance, this.interestRate)
      : super(accountNumber, balance);

  @override
  void deposit(double amount) {
    balance += amount;
    balance += amount * interestRate;  // Apply interest on deposit
  }

  @override
  bool withdraw(double amount) {
    if (balance >= amount) {
      balance -= amount;
      return true;
    }
    return false;
  }

  @override
  double getBalance() => balance;
}

class CheckingAccount extends BankAccount {
  double overdraftLimit;

  CheckingAccount(String accountNumber, double balance, this.overdraftLimit)
      : super(accountNumber, balance);

  @override
  void deposit(double amount) {
    balance += amount;
  }

  @override
  bool withdraw(double amount) {
    if (balance + overdraftLimit >= amount) {
      balance -= amount;
      return true;
    }
    return false;
  }

  @override
  double getBalance() => balance;
}

abstract class BankAccountFactory {
  BankAccount createAccount(String accountNumber, double initialBalance);
}

class SavingsAccountFactory implements BankAccountFactory {
  final double interestRate;

  SavingsAccountFactory(this.interestRate);

  @override
  BankAccount createAccount(String accountNumber, double initialBalance) {
    return SavingsAccount(accountNumber, initialBalance, interestRate);
  }
}

class CheckingAccountFactory implements BankAccountFactory {
  final double overdraftLimit;

  CheckingAccountFactory(this.overdraftLimit);

  @override
  BankAccount createAccount(String accountNumber, double initialBalance) {
    return CheckingAccount(accountNumber, initialBalance, overdraftLimit);
  }
}

// Usage
void main() {
  BankAccountFactory savingsFactory = SavingsAccountFactory(0.05);
  BankAccountFactory checkingFactory = CheckingAccountFactory(1000);

  BankAccount savingsAccount = savingsFactory.createAccount('SAV001', 1000);
  BankAccount checkingAccount = checkingFactory.createAccount('CHK001', 500);

  savingsAccount.deposit(100);
  print('Savings account balance: ${savingsAccount.getBalance()}');

  checkingAccount.withdraw(600);
  print('Checking account balance: ${checkingAccount.getBalance()}');
}
Enter fullscreen mode Exit fullscreen mode

By using the Factory Method pattern, we've created a flexible system for creating bank accounts. If we need to add a new type of account in the future, we can simply create a new class that extends BankAccount and a corresponding factory class. This approach adheres to the Open-Closed Principle, allowing us to extend our system without modifying existing code.

Abstract Factory

Image description

As "Ba Ba Bank" expands its services, we decide to offer different types of banking products, each with its own set of accounts and cards. For example, we might have a "Standard Banking" package and a "Premium Banking" package. This is where the Abstract Factory pattern becomes useful.

The Abstract Factory pattern provides an interface for creating families of related or dependent objects without specifying their concrete classes. Let's implement this pattern for our banking packages:

abstract class BankAccount {
  String getAccountDetails();
}

abstract class CreditCard {
  String getCardDetails();
}

class StandardBankAccount implements BankAccount {
  @override
  String getAccountDetails() => "Standard Bank Account";
}

class PremiumBankAccount implements BankAccount {
  @override
  String getAccountDetails() => "Premium Bank Account";
}

class StandardCreditCard implements CreditCard {
  @override
  String getCardDetails() => "Standard Credit Card";
}

class PremiumCreditCard implements CreditCard {
  @override
  String getCardDetails() => "Premium Credit Card";
}

abstract class BankingFactory {
  BankAccount createBankAccount();
  CreditCard createCreditCard();
}

class StandardBankingFactory implements BankingFactory {
  @override
  BankAccount createBankAccount() => StandardBankAccount();

  @override
  CreditCard createCreditCard() => StandardCreditCard();
}

class PremiumBankingFactory implements BankingFactory {
  @override
  BankAccount createBankAccount() => PremiumBankAccount();

  @override
  CreditCard createCreditCard() => PremiumCreditCard();
}

// Usage
void main() {
  BankingFactory standardFactory = StandardBankingFactory();
  BankingFactory premiumFactory = PremiumBankingFactory();

  BankAccount standardAccount = standardFactory.createBankAccount();
  CreditCard standardCard = standardFactory.createCreditCard();

  BankAccount premiumAccount = premiumFactory.createBankAccount();
  CreditCard premiumCard = premiumFactory.createCreditCard();

  print(standardAccount.getAccountDetails());  // Output: Standard Bank Account
  print(standardCard.getCardDetails());        // Output: Standard Credit Card
  print(premiumAccount.getAccountDetails());   // Output: Premium Bank Account
  print(premiumCard.getCardDetails());         // Output: Premium Credit Card
}
Enter fullscreen mode Exit fullscreen mode

The Abstract Factory pattern allows us to create families of related objects (in this case, banking products) without specifying their concrete classes. This makes it easy to introduce new types of banking packages in the future without modifying existing code.

Builder

Image description

As we continue developing "Ba Ba Bank", we realize that we need to generate complex financial reports. These reports might have different combinations of information depending on the user's needs or their account type. This is where the Builder pattern comes in handy.

The Builder pattern separates the construction of a complex object from its representation, allowing the same construction process to create various representations. Let's implement a financial report builder:

class FinancialReport {
  String? accountSummary;
  String? transactionHistory;
  String? creditScore;
  String? investmentOverview;

  String generateReport() {
    StringBuffer report = StringBuffer();
    if (accountSummary != null) report.writeln('Account Summary: $accountSummary');
    if (transactionHistory != null) report.writeln('Transaction History: $transactionHistory');
    if (creditScore != null) report.writeln('Credit Score: $creditScore');
    if (investmentOverview != null) report.writeln('Investment Overview: $investmentOverview');
    return report.toString();
  }
}

class FinancialReportBuilder {
  final FinancialReport _report = FinancialReport();

  FinancialReportBuilder setAccountSummary(String summary) {
    _report.accountSummary = summary;
    return this;
  }

  FinancialReportBuilder setTransactionHistory(String history) {
    _report.transactionHistory = history;
    return this;
  }

  FinancialReportBuilder setCreditScore(String score) {
    _report.creditScore = score;
    return this;
  }

  FinancialReportBuilder setInvestmentOverview(String overview) {
    _report.investmentOverview = overview;
    return this;
  }

  FinancialReport build() {
    return _report;
  }
}

// Usage
void main() {
  var standardReport = FinancialReportBuilder()
      .setAccountSummary('Balance: $5000')
      .setTransactionHistory('10 transactions in the last month')
      .build();

  var premiumReport = FinancialReportBuilder()
      .setAccountSummary('Total balance: $50000')
      .setTransactionHistory('30 transactions in the last month')
      .setCreditScore('Excellent (800)')
      .setInvestmentOverview('Stocks: 60%, Bonds: 30%, Cash: 10%')
      .build();

  print('Standard Report:\n${standardReport.generateReport()}');
  print('\nPremium Report:\n${premiumReport.generateReport()}');
}
Enter fullscreen mode Exit fullscreen mode

The Builder pattern allows us to create different types of financial reports with varying levels of detail without cluttering the FinancialReport class with numerous constructor parameters. This makes our code more readable and flexible, as we can easily add new report elements in the future.

Singleton

Image description

As we develop the user authentication system for "Ba Ba Bank", we realize that we need a way to manage the user's session across the entire application. We want to ensure that there's only one instance of the user session, accessible from anywhere in the app. This is a perfect use case for the Singleton pattern.

The Singleton pattern ensures a class has only one instance and provides a global point of access to it. Let's implement a UserSession class using the Singleton pattern:

class UserSession {
  static final UserSession _instance = UserSession._internal();

  factory UserSession() {
    return _instance;
  }

  UserSession._internal();

  String? _token;
  String? _userId;

  void login(String token, String userId) {
    _token = token;
    _userId = userId;
  }

  void logout() {
    _token = null;
    _userId = null;
  }

  bool get isLoggedIn => _token != null;
  String? get userId => _userId;

  String? getToken() {
    if (_token == null) {
      throw Exception('User is not logged in');
    }
    return _token;
  }
}

// Usage
void main() {
  final session1 = UserSession();
  final session2 = UserSession();

  print(session1 == session2);  // Output: true

  session1.login('abc123', 'user001');
  print(session2.isLoggedIn);   // Output: true
  print(session2.userId);       // Output: user001

  session2.logout();
  print(session1.isLoggedIn);   // Output: false
}
Enter fullscreen mode Exit fullscreen mode

By using the Singleton pattern for UserSession, we ensure that there's only one instance of the session throughout the app. This prevents inconsistencies that could arise from having multiple session objects and provides a centralized point for managing the user's authentication state.

Prototype

Image description

In "Ba Ba Bank", we might want to create templates for common types of transactions or account settings. The Prototype pattern can be useful in this scenario, allowing us to clone existing objects instead of creating new ones from scratch.

The Prototype pattern specifies the kinds of objects to create using a prototypical instance, and create new objects by copying this prototype. Let's implement a prototype for transaction templates:

abstract class TransactionTemplate {
  String description;
  double amount;

  TransactionTemplate(this.description, this.amount);

  TransactionTemplate clone();
}

class PaymentTemplate extends TransactionTemplate {
  String recipient;

  PaymentTemplate(String description, double amount, this.recipient)
      : super(description, amount);

  @override
  PaymentTemplate clone() {
    return PaymentTemplate(description, amount, recipient);
  }
}

class TransferTemplate extends TransactionTemplate {
  String fromAccount;
  String toAccount;

  TransferTemplate(String description, double amount, this.fromAccount, this.toAccount)
      : super(description, amount);

  @override
  TransferTemplate clone() {
    return TransferTemplate(description, amount, fromAccount, toAccount);
  }
}

// Usage
void main() {
  var rentPayment = PaymentTemplate('Monthly Rent', 1000, 'Landlord');
  var rentPaymentCopy = rentPayment.clone();
  rentPaymentCopy.amount = 1100;  // Rent increased

  var internalTransfer = TransferTemplate('Transfer to Savings', 500, 'Checking', 'Savings');
  var internalTransferCopy = internalTransfer.clone();
  internalTransferCopy.amount = 700;  // Increased transfer amount

  print('Original Rent Payment: ${rentPayment.amount}');
  print('Modified Rent Payment: ${rentPaymentCopy.amount}');

  print('Original Transfer: ${internalTransfer.amount}');
  print('Modified Transfer: ${internalTransferCopy.amount}');
}
Enter fullscreen mode Exit fullscreen mode

The Prototype pattern allows us to create new transaction templates by cloning existing ones, which can be particularly useful when we want to create variations of common transactions without starting from scratch each time.

Structural Patterns

As our "Ba Ba Bank" app continues to grow in complexity, we need to focus on how to organize our code effectively. Structural patterns help us compose classes and objects into larger structures while keeping these structures flexible and efficient.

Adapter

Image description

As we expand "Ba Ba Bank", we decide to integrate a third-party payment system into our app. However, its interface is incompatible with our existing code. This is where the Adapter pattern becomes crucial.

The Adapter pattern allows objects with incompatible interfaces to work together. It acts as a wrapper between two objects, catching calls for one object and transforming them to format and interface recognizable by the second object. Let's implement an adapter for our payment system:

// Our payment system interface
abstract class PaymentProcessor {
  Future<bool> processPayment(double amount);
}

// Third-party payment system with an incompatible interface
class ThirdPartyPaymentSystem {
  Future<Map<String, dynamic>> makePayment(int cents) async {
    // Simulating payment processing
    await Future.delayed(Duration(seconds: 1));
    return {'success': true, 'transaction_id': '12345'};
  }
}

// Adapter for the third-party payment system
class ThirdPartyPaymentAdapter implements PaymentProcessor {
  final ThirdPartyPaymentSystem _thirdPartySystem;

  ThirdPartyPaymentAdapter(this._thirdPartySystem);

  @override
  Future<bool> processPayment(double amount) async {
    var result = await _thirdPartySystem.makePayment((amount * 100).toInt());
    return result['success'] == true;
  }
}

// Usage
void main() async {
  PaymentProcessor paymentProcessor = ThirdPartyPaymentAdapter(ThirdPartyPaymentSystem());

  bool success = await paymentProcessor.processPayment(99.99);
  if (success) {
    print('Payment processed successfully');
  } else {
    print('Payment processing failed');
  }
}
Enter fullscreen mode Exit fullscreen mode

By using the Adapter pattern, we've successfully integrated the third-party payment system into our app without changing our existing PaymentProcessor interface. This makes it easy to switch between different payment providers in the future if needed.

Bridge

Image description

As "Ba Ba Bank" grows, we realize that we need to support different types of notifications (email, SMS, push notifications) for various banking operations (account activity, fraud alerts, promotional offers). The Bridge pattern can help us manage this complexity.

The Bridge pattern separates an object's abstraction from its implementation so that the two can vary independently. Let's implement this for our notification system:

// Abstraction
abstract class Notification {
  final NotificationSender sender;

  Notification(this.sender);

  void send();
}

// Implementor
abstract class NotificationSender {
  void sendNotification(String message);
}

// Concrete Abstractions
class AccountActivityNotification extends Notification {
  AccountActivityNotification(NotificationSender sender) : super(sender);

  @override
  void send() {
    sender.sendNotification("There was activity on your account.");
  }
}

class FraudAlertNotification extends Notification {
  FraudAlertNotification(NotificationSender sender) : super(sender);

  @override
  void send() {
    sender.sendNotification("Suspicious activity detected on your account!");
  }
}

// Concrete Implementors
class EmailNotificationSender implements NotificationSender {
  @override
  void sendNotification(String message) {
    print("Sending Email: $message");
  }
}

class SMSNotificationSender implements NotificationSender {
  @override
  void sendNotification(String message) {
    print("Sending SMS: $message");
  }
}

// Usage
void main() {
  var emailSender = EmailNotificationSender();
  var smsSender = SMSNotificationSender();

  var accountNotificationEmail = AccountActivityNotification(emailSender);
  var fraudNotificationSMS = FraudAlertNotification(smsSender);

  accountNotificationEmail.send();
  fraudNotificationSMS.send();
}
Enter fullscreen mode Exit fullscreen mode

The Bridge pattern allows us to combine different types of notifications with different sending methods, making our system more flexible and easier to extend.

Composite

Image description

In "Ba Ba Bank", we want to create a feature that allows users to group their accounts and view combined balances. The Composite pattern is perfect for this scenario.

The Composite pattern lets you compose objects into tree structures to represent part-whole hierarchies. It lets clients treat individual objects and compositions of objects uniformly. Here's how we can implement it:

abstract class AccountComponent {
  String getName();
  double getBalance();
}

class BankAccount implements AccountComponent {
  String name;
  double balance;

  BankAccount(this.name, this.balance);

  @override
  String getName() => name;

  @override
  double getBalance() => balance;
}

class AccountGroup implements AccountComponent {
  String name;
  List<AccountComponent> accounts = [];

  AccountGroup(this.name);

  void addAccount(AccountComponent account) {
    accounts.add(account);
  }

  @override
  String getName() => name;

  @override
  double getBalance() {
    return accounts.fold(0, (sum, account) => sum + account.getBalance());
  }
}

// Usage
void main() {
  var checkingAccount = BankAccount('Checking', 1000);
  var savingsAccount = BankAccount('Savings', 2000);
  var stocksAccount = BankAccount('Stocks', 5000);

  var personalAccounts = AccountGroup('Personal Accounts');
  personalAccounts.addAccount(checkingAccount);
  personalAccounts.addAccount(savingsAccount);

  var rootGroup = AccountGroup('All Accounts');
  rootGroup.addAccount(personalAccounts);
  rootGroup.addAccount(stocksAccount);

  print('Total Balance: ${rootGroup.getBalance()}');  // Output: Total Balance: 8000
  print('Personal Accounts Balance: ${personalAccounts.getBalance()}');  // Output: Personal Accounts Balance: 3000
}
Enter fullscreen mode Exit fullscreen mode

The Composite pattern allows us to create complex account structures while still being able to work with individual accounts and groups of accounts through the same interface.

Decorator

Image description

As we develop the account features for "Ba Ba Bank", we realize that we want to be able to add additional behaviors to accounts dynamically. For example, we might want to add overdraft protection or automatic savings to certain accounts. The Decorator pattern is ideal for this situation.

The Decorator pattern allows behavior to be added to individual objects, either statically or dynamically, without affecting the behavior of other objects from the same class. Here's how we can implement it:

abstract class Account {
  String getDescription();
  double getBalance();
  void credit(double amount);
  bool debit(double amount);
}

class BasicAccount implements Account {
  String _description;
  double _balance;

  BasicAccount(this._description, this._balance);

  @override
  String getDescription() => _description;

  @override
  double getBalance() => _balance;

  @override
  void credit(double amount) {
    _balance += amount;
  }

  @override
  bool debit(double amount) {
    if (_balance >= amount) {
      _balance -= amount;
      return true;
    }
    return false;
  }
}

abstract class AccountDecorator implements Account {
  final Account _account;

  AccountDecorator(this._account);

  @override
  String getDescription() => _account.getDescription();

  @override
  double getBalance() => _account.getBalance();

  @override
  void credit(double amount) => _account.credit(amount);

  @override
  bool debit(double amount) => _account.debit(amount);
}

class OverdraftProtection extends AccountDecorator {
  double _overdraftLimit;

  OverdraftProtection(Account account, this._overdraftLimit) : super(account);

  @override
  String getDescription() => '${super.getDescription()} with Overdraft Protection';

  @override
  bool debit(double amount) {
    if (super.getBalance() + _overdraftLimit >= amount) {
      super.debit(amount);
      return true;
    }
    return false;
  }
}

class AutoSave extends AccountDecorator {
  double _savePercentage;

  AutoSave(Account account, this._savePercentage) : super(account);

  @override
  String getDescription() => '${super.getDescription()} with Auto Save';

  @override
  void credit(double amount) {
    double saveAmount = amount * _savePercentage;
    super.credit(amount - saveAmount);
    print('Saved $saveAmount');
  }
}

// Usage
void main() {
  Account account = BasicAccount('Checking Account', 1000);
  account = OverdraftProtection(account, 500);
  account = AutoSave(account, 0.1);

  print(account.getDescription());
  print('Initial balance: ${account.getBalance()}');

  account.credit(200);
  print('After credit: ${account.getBalance()}');

  bool success = account.debit(1500);
  print('Debit ${success ? 'succeeded' : 'failed'}. New balance: ${account.getBalance()}');
}
Enter fullscreen mode Exit fullscreen mode

The Decorator pattern allows us to add new behaviors to our accounts dynamically, without altering the existing account classes. This makes our system more flexible and easier to extend with new features.

Facade

Image description

As "Ba Ba Bank" grows more complex, we need a way to simplify the interface for common banking operations. The Facade pattern can help us achieve this.

The Facade pattern provides a simplified interface to a complex subsystem. Let's implement a BankingFacade that simplifies the process of transferring money between accounts:

class AccountManager {
  bool verifyAccount(String accountNumber) {
    // Verify account logic
    print('Verifying account $accountNumber');
    return true;
  }
}

class FundsManager {
  bool checkSufficientFunds(String accountNumber, double amount) {
    // Check funds logic
    print('Checking funds in account $accountNumber');
    return true;
  }
}

class TransferManager {
  void transfer(String fromAccount, String toAccount, double amount) {
    // Transfer logic
    print('Transferring $amount from $fromAccount to $toAccount');
  }
}

class NotificationManager {
  void sendNotification(String accountNumber, String message) {
    // Notification logic
    print('Sending notification to account $accountNumber: $message');
  }
}

class BankingFacade {
  final AccountManager _accountManager = AccountManager();
  final FundsManager _fundsManager = FundsManager();
  final TransferManager _transferManager = TransferManager();
  final NotificationManager _notificationManager = NotificationManager();

  bool transferMoney(String fromAccount, String toAccount, double amount) {
    if (!_accountManager.verifyAccount(fromAccount) || 
        !_accountManager.verifyAccount(toAccount)) {
      return false;
    }

    if (!_fundsManager.checkSufficientFunds(fromAccount, amount)) {
      return false;
    }

    _transferManager.transfer(fromAccount, toAccount, amount);
    _notificationManager.sendNotification(fromAccount, 'Transfer of $amount sent to $toAccount');
    _notificationManager.sendNotification(toAccount, 'Transfer of $amount received from $fromAccount');

    return true;
  }
}

// Usage
void main() {
  var facade = BankingFacade();
  facade.transferMoney('123456', '789012', 500);
}
Enter fullscreen mode Exit fullscreen mode

The Facade pattern simplifies the complex process of transferring money between accounts into a single method call, hiding the complexities of account verification, funds checking, transfer execution, and notification sending from the client code.

Flyweight

Image description

In "Ba Ba Bank", we might have a large number of transactions that share common properties. The Flyweight pattern can help us save memory by sharing common parts of state between multiple objects instead of keeping all of the data in each object.

The Flyweight pattern is used to minimize memory usage by sharing as much data as possible with other similar objects. Here's how we could implement it for transaction types:

class TransactionType {
  final String name;
  final String description;

  TransactionType(this.name, this.description);
}

class TransactionTypeFactory {
  final Map<String, TransactionType> _transactionTypes = {};

  TransactionType getTransactionType(String name) {
    if (!_transactionTypes.containsKey(name)) {
      switch (name) {
        case 'deposit':
          _transactionTypes[name] = TransactionType('deposit', 'Money added to account');
          break;
        case 'withdrawal':
          _transactionTypes[name] = TransactionType('withdrawal', 'Money removed from account');
          break;
        case 'transfer':
          _transactionTypes[name] = TransactionType('transfer', 'Money moved between accounts');
          break;
        default:
          throw ArgumentError('Unknown transaction type');
      }
    }
    return _transactionTypes[name]!;
  }
}

class Transaction {
  final TransactionType type;
  final double amount;
  final DateTime date;

  Transaction(this.type, this.amount, this.date);

  void display() {
    print('${type.name.toUpperCase()}: $amount on ${date.toIso8601String()}');
    print('Description: ${type.description}');
  }
}

// Usage
void main() {
  var factory = TransactionTypeFactory();
  var transactions = [
    Transaction(factory.getTransactionType('deposit'), 100, DateTime.now()),
    Transaction(factory.getTransactionType('withdrawal'), 50, DateTime.now()),
    Transaction(factory.getTransactionType('transfer'), 75, DateTime.now()),
    Transaction(factory.getTransactionType('deposit'), 200, DateTime.now()),
  ];

  for (var transaction in transactions) {
    transaction.display();
    print('---');
  }
}
Enter fullscreen mode Exit fullscreen mode

The Flyweight pattern allows us to share the common TransactionType objects among multiple Transaction objects, potentially saving memory when dealing with a large number of transactions.

Proxy

Image description

In "Ba Ba Bank", we might want to control access to certain sensitive operations or implement lazy loading of resource-heavy objects. The Proxy pattern can help us achieve this.

The Proxy pattern provides a surrogate or placeholder for another object to control access to it. Here's an example of how we might use a proxy to control access to a user's account information:

abstract class AccountInfo {
  void displayAccountInfo();
}

class RealAccountInfo implements AccountInfo {
  String accountNumber;

  RealAccountInfo(this.accountNumber) {
    _loadAccountInfo();
  }

  void _loadAccountInfo() {
    print('Loading account info for $accountNumber from database...');
    // Simulate loading data from database
  }

  @override
  void displayAccountInfo() {
    print('Account Number: $accountNumber');
    print('Balance: \$1,000,000');  // Just an example
  }
}

class AccountInfoProxy implements AccountInfo {
  late RealAccountInfo _realAccountInfo;
  String accountNumber;
  String? _userRole;

  AccountInfoProxy(this.accountNumber, this._userRole);

  @override
  void displayAccountInfo() {
    if (_userRole == 'admin' || _userRole == 'account_owner') {
      _realAccountInfo = RealAccountInfo(accountNumber);
      _realAccountInfo.displayAccountInfo();
    } else {
      print('Access denied. You do not have permission to view this account info.');
    }
  }
}

// Usage
void main() {
  var adminProxy = AccountInfoProxy('12345', 'admin');
  var userProxy = AccountInfoProxy('12345', 'user');

  print('Admin accessing account info:');
  adminProxy.displayAccountInfo();

  print('\nRegular user accessing account info:');
  userProxy.displayAccountInfo();
}
Enter fullscreen mode Exit fullscreen mode

The Proxy pattern allows us to add a level of indirection when accessing an object. In this case, we're using it to control access to account information based on the user's role, and to implement lazy loading of the account information.

Behavioral Patterns

Behavioral patterns are concerned with algorithms and the assignment of responsibilities between objects. They characterize complex control flow that's difficult to follow at run-time. Let's explore how these patterns can be applied in our "Ba Ba Bank" application.

Observer

Image description

In "Ba Ba Bank", we want to implement a feature that notifies users of any changes to their account balance. The Observer pattern is perfect for this scenario.

The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. Here's how we can implement it:

abstract class Subject {
  void registerObserver(Observer observer);
  void removeObserver(Observer observer);
  void notifyObservers();
}

abstract class Observer {
  void update(double balance);
}

class Account implements Subject {
  double _balance = 0;
  final List<Observer> _observers = [];

  double get balance => _balance;

  void deposit(double amount) {
    _balance += amount;
    notifyObservers();
  }

  void withdraw(double amount) {
    if (_balance >= amount) {
      _balance -= amount;
      notifyObservers();
    }
  }

  @override
  void registerObserver(Observer observer) {
    _observers.add(observer);
  }

  @override
  void removeObserver(Observer observer) {
    _observers.remove(observer);
  }

  @override
  void notifyObservers() {
    for (var observer in _observers) {
      observer.update(_balance);
    }
  }
}

class BalanceDisplay implements Observer {
  @override
  void update(double balance) {
    print('Balance Display: Current balance is $balance');
  }
}

class MobileNotification implements Observer {
  @override
  void update(double balance) {
    print('Mobile Notification: Your balance has changed. New balance: $balance');
  }
}

// Usage
void main() {
  var account = Account();
  var display = BalanceDisplay();
  var notification = MobileNotification();

  account.registerObserver(display);
  account.registerObserver(notification);

  account.deposit(1000);
  account.withdraw(500);
}
Enter fullscreen mode Exit fullscreen mode

The Observer pattern allows us to create a flexible notification system where multiple observers (like a balance display and a mobile notification system) can be notified of changes to an account's balance without tightly coupling these components to the Account class.

Strategy

Image description

In "Ba Ba Bank", we want to offer different saving strategies for our customers. The Strategy pattern can help us implement this feature.

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from clients that use it. Here's how we can implement it:

abstract class SavingsStrategy {
  double calculateInterest(double balance);
}

class ConservativeSavingsStrategy implements SavingsStrategy {
  @override
  double calculateInterest(double balance) {
    return balance * 0.01;  // 1% interest
  }
}

class ModerateSavingsStrategy implements SavingsStrategy {
  @override
  double calculateInterest(double balance) {
    return balance * 0.02;  // 2% interest
  }
}

class AggressiveSavingsStrategy implements SavingsStrategy {
  @override
  double calculateInterest(double balance) {
    return balance * 0.05;  // 5% interest
  }
}

class SavingsAccount {
  double _balance;
  SavingsStrategy _strategy;

  SavingsAccount(this._balance, this._strategy);

  void setStrategy(SavingsStrategy strategy) {
    _strategy = strategy;
  }

  void addInterest() {
    double interest = _strategy.calculateInterest(_balance);
    _balance += interest;
    print('Added interest: $interest. New balance: $_balance');
  }
}

// Usage
void main() {
  var account = SavingsAccount(1000, ConservativeSavingsStrategy());
  account.addInterest();

  account.setStrategy(ModerateSavingsStrategy());
  account.addInterest();

  account.setStrategy(AggressiveSavingsStrategy());
  account.addInterest();
}
Enter fullscreen mode Exit fullscreen mode

The Strategy pattern allows us to define a family of algorithms (in this case, different savings strategies), encapsulate each one, and make them interchangeable. This makes it easy to add new strategies in the future without modifying the SavingsAccount class.

Command

Image description

In "Ba Ba Bank", we want to implement a transaction system that supports undoing operations. The Command pattern is ideal for this scenario.

The Command pattern turns a request into a stand-alone object that contains all information about the request. This transformation lets you pass requests as a method arguments, delay or queue a request's execution, and support undoable operations. Here's how we can implement it:

abstract class Command {
  void execute();
  void undo();
}

class Account {
  double _balance = 0;

  void deposit(double amount) {
    _balance += amount;
    print('Deposited $amount. New balance: $_balance');
  }

  void withdraw(double amount) {
    if (_balance >= amount) {
      _balance -= amount;
      print('Withdrawn $amount. New balance: $_balance');
    } else {
      print('Insufficient funds');
    }
  }

  double get balance => _balance;
}

class DepositCommand implements Command {
  final Account _account;
  final double _amount;

  DepositCommand(this._account, this._amount);

  @override
  void execute() {
    _account.deposit(_amount);
  }

  @override
  void undo() {
    _account.withdraw(_amount);
  }
}

class WithdrawCommand implements Command {
  final Account _account;
  final double _amount;

  WithdrawCommand(this._account, this._amount);

  @override
  void execute() {
    _account.withdraw(_amount);
  }

  @override
  void undo() {
    _account.deposit(_amount);
  }
}

class TransactionManager {
  final List<Command> _commands = [];
  int _currentIndex = -1;

  void executeCommand(Command command) {
    command.execute();
    _commands.add(command);
    _currentIndex++;
  }

  void undo() {
    if (_currentIndex >= 0) {
      _commands[_currentIndex].undo();
      _currentIndex--;
    } else {
      print('No more commands to undo');
    }
  }

  void redo() {
    if (_currentIndex < _commands.length - 1) {
      _currentIndex++;
      _commands[_currentIndex].execute();
    } else {
      print('No more commands to redo');
    }
  }
}

// Usage
void main() {
  var account = Account();
  var manager = TransactionManager();

  manager.executeCommand(DepositCommand(account, 100));
  manager.executeCommand(WithdrawCommand(account, 50));
  manager.executeCommand(DepositCommand(account, 200));

  print('Current balance: ${account.balance}');

  manager.undo();
  print('After undo: ${account.balance}');

  manager.redo();
  print('After redo: ${account.balance}');
}
Enter fullscreen mode Exit fullscreen mode

The Command pattern allows us to encapsulate each bank transaction as a Command object. This makes it easy to implement undo and redo functionality, as well as to queue or log transactions.

State

Image description

In "Ba Ba Bank", we want to implement different account states (e.g., standard, gold, platinum) with different behaviors. The State pattern can help us manage this complexity.

The State pattern allows an object to alter its behavior when its internal state changes. The object will appear to change its class. Here's how we can implement it:

abstract class AccountState {
  void deposit(Account account, double amount);
  void withdraw(Account account, double amount);
}

class StandardState implements AccountState {
  @override
  void deposit(Account account, double amount) {
    account.balance += amount;
    print('Standard account: Deposited $amount. New balance: ${account.balance}');
    if (account.balance > 10000) {
      account.setState(GoldState());
    }
  }

  @override
  void withdraw(Account account, double amount) {
    if (account.balance >= amount) {
      account.balance -= amount;
      print('Standard account: Withdrawn $amount. New balance: ${account.balance}');
    } else {
      print('Standard account: Insufficient funds');
    }
  }
}

class GoldState implements AccountState {
  @override
  void deposit(Account account, double amount) {
    account.balance += amount * 1.1;  // 10% bonus
    print('Gold account: Deposited $amount with 10% bonus. New balance: ${account.balance}');
    if (account.balance > 20000) {
      account.setState(PlatinumState());
    }
  }

  @override
  void withdraw(Account account, double amount) {
    if (account.balance >= amount) {
      account.balance -= amount;
      print('Gold account: Withdrawn $amount. New balance: ${account.balance}');
    } else {
      print('Gold account: Insufficient funds');
    }
    if (account.balance < 10000) {
      account.setState(StandardState());
    }
  }
}

class PlatinumState implements AccountState {
  @override
  void deposit(Account account, double amount) {
    account.balance += amount * 1.2;  // 20% bonus
    print('Platinum account: Deposited $amount with 20% bonus. New balance: ${account.balance}');
  }

  @override
  void withdraw(Account account, double amount) {
    account.balance -= amount;
    print('Platinum account: Withdrawn $amount. New balance: ${account.balance}');
    if (account.balance < 20000) {
      account.setState(GoldState());
    }
  }
}

class Account {
  AccountState _state;
  double _balance = 0;

  Account() : _state = StandardState();

  void setState(AccountState state) {
    _state = state;
    print('Account state changed to ${state.runtimeType}');
  }

  void deposit(double amount) {
    _state.deposit(this, amount);
  }

  void withdraw(double amount) {
    _state.withdraw(this, amount);
  }

  double get balance => _balance;
  set balance(double value) => _balance = value;
}

// Usage
void main() {
  var account = Account();

  account.deposit(5000);
  account.deposit(7000);
  account.withdraw(3000);
  account.deposit(15000);
  account.withdraw(10000);
}
Enter fullscreen mode Exit fullscreen mode

The State pattern allows the Account object to change its behavior when its balance changes, without using large conditional statements. Each state is encapsulated in its own class, making it easy to add new states or modify existing ones.

Chain of Responsibility

Image description

In "Ba Ba Bank", we want to implement a loan approval system where different levels of employees can approve loans of different sizes. The Chain of Responsibility pattern is perfect for this scenario.

The Chain of Responsibility pattern passes requests along a chain of handlers. Upon receiving a request, each handler decides either to process the request or to pass it to the next handler in the chain. Here's how we can implement it:

abstract class LoanHandler {
  LoanHandler? _nextHandler;

  void setNext(LoanHandler handler) {
    _nextHandler = handler;
  }

  void handleRequest(Loan loan);
}

class Loan {
  final double amount;

  Loan(this.amount);
}

class JuniorOfficer extends LoanHandler {
  @override
  void handleRequest(Loan loan) {
    if (loan.amount <= 10000) {
      print('Junior Officer approves loan of ${loan.amount}');
    } else if (_nextHandler != null) {
      _nextHandler!.handleRequest(loan);
    }
  }
}

class SeniorOfficer extends LoanHandler {
  @override
  void handleRequest(Loan loan) {
    if (loan.amount <= 50000) {
      print('Senior Officer approves loan of ${loan.amount}');
    } else if (_nextHandler != null) {
      _nextHandler!.handleRequest(loan);
    }
  }
}

class Manager extends LoanHandler {
  @override
  void handleRequest(Loan loan) {
    if (loan.amount <= 100000) {
      print('Manager approves loan of ${loan.amount}');
    } else {
      print('Loan of ${loan.amount} requires executive approval');
    }
  }
}

// Usage
void main() {
  var juniorOfficer = JuniorOfficer();
  var seniorOfficer = SeniorOfficer();
  var manager = Manager();

  juniorOfficer.setNext(seniorOfficer);
  seniorOfficer.setNext(manager);

  juniorOfficer.handleRequest(Loan(5000));
  juniorOfficer.handleRequest(Loan(25000));
  juniorOfficer.handleRequest(Loan(75000));
  juniorOfficer.handleRequest(Loan(200000));
}
Enter fullscreen mode Exit fullscreen mode

The Chain of Responsibility pattern allows us to create a chain of loan handlers. Each handler has the ability to process a request or pass it to the next handler in the chain. This makes it easy to add new handlers or change the order of handlers without modifying the client code.

Memento

Image description

In "Ba Ba Bank", we want to implement a feature that allows users to save and restore the state of their account settings. The Memento pattern is ideal for this scenario.

The Memento pattern lets you save and restore the previous state of an object without revealing the details of its implementation. Here's how we can implement it:

class AccountSettings {
  bool _notificationsEnabled;
  String _language;
  String _theme;

  AccountSettings(this._notificationsEnabled, this._language, this._theme);

  void setSettings(bool notifications, String language, String theme) {
    _notificationsEnabled = notifications;
    _language = language;
    _theme = theme;
  }

  AccountSettingsMemento save() {
    return AccountSettingsMemento(_notificationsEnabled, _language, _theme);
  }

  void restore(AccountSettingsMemento memento) {
    _notificationsEnabled = memento.notificationsEnabled;
    _language = memento.language;
    _theme = memento.theme;
  }

  @override
  String toString() {
    return 'AccountSettings: {notifications: $_notificationsEnabled, language: $_language, theme: $_theme}';
  }
}

class AccountSettingsMemento {
  final bool notificationsEnabled;
  final String language;
  final String theme;

  AccountSettingsMemento(this.notificationsEnabled, this.language, this.theme);
}

class AccountSettingsCaretaker {
  AccountSettingsMemento? _memento;

  void saveSettings(AccountSettings settings) {
    _memento = settings.save();
  }

  void restoreSettings(AccountSettings settings) {
    if (_memento != null) {
      settings.restore(_memento!);
    }
  }
}

// Usage
void main() {
  var settings = AccountSettings(true, 'English', 'Light');
  var caretaker = AccountSettingsCaretaker();

  print('Original settings: $settings');

  caretaker.saveSettings(settings);

  settings.setSettings(false, 'Spanish', 'Dark');
  print('Modified settings: $settings');

  caretaker.restoreSettings(settings);
  print('Restored settings: $settings');
}
Enter fullscreen mode Exit fullscreen mode

The Memento pattern allows us to save and restore the state of the AccountSettings object without exposing its internal structure. This is particularly useful for implementing "undo" functionality or for saving user preferences.

Architectural Patterns

Architectural patterns are high-level strategies that concern large-scale components and the global properties and mechanisms of a system. They provide an organized structure to our Flutter application, making it easier to develop, maintain, and scale.

MVVM (Model-View-ViewModel)

Image description

MVVM is a design pattern that separates the development of the graphical user interface (View) from the business logic and data (Model) via an intermediary (ViewModel). This separation enhances modularity and makes it easier to maintain and test the code. In "Ba Ba Bank", we can use MVVM to structure our account overview screen.

Here's how we might implement MVVM in our Flutter app:

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

// Model
class Account {
  String id;
  String type;
  double balance;

  Account(this.id, this.type, this.balance);
}

// ViewModel
class AccountViewModel extends ChangeNotifier {
  Account _account;

  AccountViewModel(this._account);

  String get accountId => _account.id;
  String get accountType => _account.type;
  double get balance => _account.balance;

  void deposit(double amount) {
    _account.balance += amount;
    notifyListeners();
  }

  void withdraw(double amount) {
    if (_account.balance >= amount) {
      _account.balance -= amount;
      notifyListeners();
    }
  }
}

// View
class AccountView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => AccountViewModel(Account('123', 'Savings', 1000)),
      child: Consumer<AccountViewModel>(
        builder: (context, viewModel, child) {
          return Scaffold(
            appBar: AppBar(title: Text('Account Overview')),
            body: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text('Account ID: ${viewModel.accountId}'),
                  Text('Type: ${viewModel.accountType}'),
                  Text('Balance: \$${viewModel.balance.toStringAsFixed(2)}'),
                  ElevatedButton(
                    child: Text('Deposit \$100'),
                    onPressed: () => viewModel.deposit(100),
                  ),
                  ElevatedButton(
                    child: Text('Withdraw \$50'),
                    onPressed: () => viewModel.withdraw(50),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

// Usage
void main() {
  runApp(MaterialApp(home: AccountView()));
}
Enter fullscreen mode Exit fullscreen mode

In this MVVM implementation:

  • The Model (Account) represents the data and business logic.
  • The ViewModel (AccountViewModel) acts as an intermediary between the Model and the View. It exposes data and commands that the View can use.
  • The View (AccountView) is responsible for the UI layout and binds to properties and commands exposed by the ViewModel.

MVVM allows us to separate concerns, making our code more modular and easier to test. The ViewModel can be tested independently of the UI, and the View can be easily modified without affecting the underlying logic.

BLoC (Business Logic Component)

Image description

BLoC is a design pattern that helps separate the presentation layer from the business logic. It relies heavily on streams and reactive programming. In "Ba Ba Bank", we can use BLoC to manage the state of a transaction history screen.

Here's an example of how we might implement BLoC:

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

// Events
abstract class TransactionEvent {}

class LoadTransactions extends TransactionEvent {}
class AddTransaction extends TransactionEvent {
  final String description;
  final double amount;

  AddTransaction(this.description, this.amount);
}

// States
abstract class TransactionState {}

class TransactionInitial extends TransactionState {}
class TransactionLoading extends TransactionState {}
class TransactionLoaded extends TransactionState {
  final List<Transaction> transactions;

  TransactionLoaded(this.transactions);
}

// Model
class Transaction {
  final String id;
  final String description;
  final double amount;
  final DateTime date;

  Transaction(this.id, this.description, this.amount, this.date);
}

// BLoC
class TransactionBloc extends Bloc<TransactionEvent, TransactionState> {
  TransactionBloc() : super(TransactionInitial());

  @override
  Stream<TransactionState> mapEventToState(TransactionEvent event) async* {
    if (event is LoadTransactions) {
      yield TransactionLoading();
      // Simulating API call
      await Future.delayed(Duration(seconds: 1));
      yield TransactionLoaded([
        Transaction('1', 'Grocery Shopping', -50.0, DateTime.now().subtract(Duration(days: 1))),
        Transaction('2', 'Salary', 1000.0, DateTime.now().subtract(Duration(days: 2))),
      ]);
    } else if (event is AddTransaction) {
      if (state is TransactionLoaded) {
        final currentTransactions = (state as TransactionLoaded).transactions;
        yield TransactionLoaded([
          Transaction(
            DateTime.now().millisecondsSinceEpoch.toString(),
            event.description,
            event.amount,
            DateTime.now(),
          ),
          ...currentTransactions,
        ]);
      }
    }
  }
}

// View
class TransactionHistoryPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => TransactionBloc()..add(LoadTransactions()),
      child: Scaffold(
        appBar: AppBar(title: Text('Transaction History')),
        body: BlocBuilder<TransactionBloc, TransactionState>(
          builder: (context, state) {
            if (state is TransactionLoading) {
              return Center(child: CircularProgressIndicator());
            } else if (state is TransactionLoaded) {
              return ListView.builder(
                itemCount: state.transactions.length,
                itemBuilder: (context, index) {
                  final transaction = state.transactions[index];
                  return ListTile(
                    title: Text(transaction.description),
                    subtitle: Text(transaction.date.toString()),
                    trailing: Text('\$${transaction.amount.toStringAsFixed(2)}'),
                  );
                },
              );
            }
            return Center(child: Text('No transactions'));
          },
        ),
        floatingActionButton: FloatingActionButton(
          child: Icon(Icons.add),
          onPressed: () {
            context.read<TransactionBloc>().add(AddTransaction('New Transaction', -30.0));
          },
        ),
      ),
    );
  }
}

// Usage
void main() {
  runApp(MaterialApp(home: TransactionHistoryPage()));
}
Enter fullscreen mode Exit fullscreen mode

In this BLoC implementation:

  • Events (TransactionEvent) represent user actions or system events.
  • States (TransactionState) represent the different states our UI can be in.
  • The BLoC (TransactionBloc) manages the business logic, converting events to states.
  • The View (TransactionHistoryPage) responds to state changes and sends events to the BLoC.

BLoC helps us manage complex state in a predictable way, making our app more maintainable and testable.

Repository Pattern

Image description

The Repository pattern adds an abstraction layer between the data source and the business logic of an application. In "Ba Ba Bank", we can use this pattern to abstract away the details of how we fetch and store user data.

Here's an example of how we might implement the Repository pattern:

import 'dart:async';

// Model
class User {
  final String id;
  final String name;
  final String email;

  User(this.id, this.name, this.email);

  factory User.fromJson(Map<String, dynamic> json) {
    return User(json['id'], json['name'], json['email']);
  }

  Map<String, dynamic> toJson() {
    return {'id': id, 'name': name, 'email': email};
  }
}

// Repository Interface
abstract class UserRepository {
  Future<User> getUser(String id);
  Future<void> updateUser(User user);
}

// API Implementation
class ApiUserRepository implements UserRepository {
  @override
  Future<User> getUser(String id) async {
    // Simulating API call
    await Future.delayed(Duration(seconds: 1));
    return User(id, 'John Doe', 'john@example.com');
  }

  @override
  Future<void> updateUser(User user) async {
    // Simulating API call
    await Future.delayed(Duration(seconds: 1));
    print('User updated: ${user.toJson()}');
  }
}

// Local Storage Implementation
class LocalUserRepository implements UserRepository {
  final Map<String, User> _storage = {};

  @override
  Future<User> getUser(String id) async {
    // Simulating local storage retrieval
    await Future.delayed(Duration(milliseconds: 100));
    return _storage[id] ?? User(id, 'Unknown', 'unknown@example.com');
  }

  @override
  Future<void> updateUser(User user) async {
    // Simulating local storage update
    await Future.delayed(Duration(milliseconds: 100));
    _storage[user.id] = user;
    print('User updated locally: ${user.toJson()}');
  }
}

// Usage
void main() async {
  UserRepository apiRepo = ApiUserRepository();
  UserRepository localRepo = LocalUserRepository();

  var user = await apiRepo.getUser('123');
  print('User from API: ${user.name}');

  await localRepo.updateUser(user);

  var localUser = await localRepo.getUser('123');
  print('User from local storage: ${localUser.name}');
}
Enter fullscreen mode Exit fullscreen mode

The Repository pattern allows us to abstract away the data source, making it easy to switch between different data sources (like API and local storage) without changing the rest of our application code. This is particularly useful in mobile applications where we might want to cache data locally for offline use.

Clean Architecture

Image description

Clean Architecture is a software design philosophy that separates the elements of a design into ring levels. The main rule of clean architecture is that code dependencies can only come from the outer levels inward. In "Ba Ba Bank", we can use Clean Architecture to structure our entire application.

Here's a simplified example of how we might implement Clean Architecture:

// Entities (Enterprise Business Rules)
class Account {
  final String id;
  double balance;

  Account(this.id, this.balance);
}

// Use Cases (Application Business Rules)
abstract class TransferMoney {
  Future<void> execute(String fromId, String toId, double amount);
}

class TransferMoneyUseCase implements TransferMoney {
  final AccountRepository repository;

  TransferMoneyUseCase(this.repository);

  @override
  Future<void> execute(String fromId, String toId, double amount) async {
    var fromAccount = await repository.getAccount(fromId);
    var toAccount = await repository.getAccount(toId);

    if (fromAccount.balance < amount) {
      throw Exception('Insufficient funds');
    }

    fromAccount.balance -= amount;
    toAccount.balance += amount;

    await repository.updateAccount(fromAccount);
    await repository.updateAccount(toAccount);
  }
}

// Interface Adapters
abstract class AccountRepository {
  Future<Account> getAccount(String id);
  Future<void> updateAccount(Account account);
}

class AccountRepositoryImpl implements AccountRepository {
  // This could be using an API client or local database
  final Map<String, Account> _accounts = {
    '1': Account('1', 1000),
    '2': Account('2', 500),
  };

  @override
  Future<Account> getAccount(String id) async {
    await Future.delayed(Duration(milliseconds: 100)); // Simulating I/O
    return _accounts[id] ?? Account(id, 0);
  }

  @override
  Future<void> updateAccount(Account account) async {
    await Future.delayed(Duration(milliseconds: 100)); // Simulating I/O
    _accounts[account.id] = account;
  }
}

// Frameworks & Drivers (UI, Database, External Interfaces)
class TransferMoneyViewModel {
  final TransferMoney transferMoney;

  TransferMoneyViewModel(this.transferMoney);

  Future<void> transfer(String fromId, String toId, double amount) async {
    try {
      await transferMoney.execute(fromId, toId, amount);
      print('Transfer successful');
    } catch (e) {
      print('Transfer failed: ${e.toString()}');
    }
  }
}

// Usage
void main() async {
  var repository = AccountRepositoryImpl();
  var useCase = TransferMoneyUseCase(repository);
  var viewModel = TransferMoneyViewModel(useCase);

  await viewModel.transfer('1', '2', 200);

  var account1 = await repository.getAccount('1');
  var account2 = await repository.getAccount('2');

  print('Account 1 balance: ${account1.balance}');
  print('Account 2 balance: ${account2.balance}');
}
Enter fullscreen mode Exit fullscreen mode

In this Clean Architecture implementation:

  • Entities represent the core business objects.
  • Use Cases contain the business logic of the application.
  • The Repository is an interface adapter that abstracts the data source.
  • The ViewModel acts as a presenter, connecting the UI to the Use Cases.

Clean Architecture helps us create a system that is independent of frameworks, testable, and independent of the UI. This makes our "Ba Ba Bank" app more maintainable and adaptable to change.

When to Use Design Patterns

While design patterns offer numerous benefits, it's crucial to use them judiciously. Here are some guidelines on when to apply design patterns:

  1. Understand the problem: Before applying a pattern, make sure you fully understand the problem you're trying to solve.

  2. Start simple: Don't overcomplicate your code with patterns if a simple solution works well.

  3. Consider future changes: Apply patterns when you anticipate that the code will need to change or expand in the future.

  4. Evaluate trade-offs: Each pattern has its advantages and potential drawbacks. Assess whether the benefits outweigh any disadvantages.

  5. Follow YAGNI (You Aren't Gonna Need It): Don't add complexity by implementing functionality "just in case."

  6. Use patterns consistently: If you decide to use a certain pattern, apply it consistently throughout your project.

  7. Document pattern usage: Ensure that other developers on the team understand which patterns are being used and why.

Remember, the goal is not to use as many patterns as possible, but to solve problems effectively and create robust, scalable software.

Conclusion

Throughout the development of our "BBB - Ba Ba Bank" application, we've explored how various design patterns can be applied to solve common problems and improve the structure of our code. From managing object creation with creational patterns to organizing complex structures with structural patterns, defining object interactions with behavioral patterns, and structuring our application with architectural patterns, each pattern has played a crucial role in building a robust and maintainable banking app.

By understanding these patterns and their appropriate use cases, you can write more efficient, readable, and maintainable Flutter applications. Remember that the effectiveness of a pattern depends on the specific context of your project. Always consider the trade-offs and choose the solution that best fits your needs.

As you continue to develop "Ba Ba Bank" and other Flutter applications, keep exploring these patterns, practice implementing them, and always strive to understand the underlying principles that make them effective.

Additional Resources

To further your understanding of design patterns in software development and Flutter specifically, consider exploring these resources:

  1. "Design Patterns: Elements of Reusable Object-Oriented Software" by Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides - The classic book on design patterns.

  2. "Flutter Design Patterns" by Mangirdas Kazlauskas - A comprehensive guide on implementing design patterns in Flutter.

  3. Dart Design Patterns website (https://dart.dev/guides/language/effective-dart/design) - Official Dart language design patterns guide.

  4. Flutter documentation (https://flutter.dev/docs) - The official Flutter documentation, which often demonstrates best practices and pattern usage.

  5. "Clean Architecture: A Craftsman's Guide to Software Structure and Design" by Robert C. Martin - A great resource for understanding clean architecture principles.

Remember, the best way to master design patterns is through practice. Try implementing these patterns in your Flutter projects, analyze their impact, and continually refine your approach to software design.

As you've seen throughout this article, design patterns are powerful tools that can greatly enhance the quality, maintainability, and scalability of your Flutter applications. However, it's important to remember that patterns are not a one-size-fits-all solution. Each pattern should be applied thoughtfully, considering the specific needs and constraints of your project.

In our "BBB - Ba Ba Bank" application, we've demonstrated how various patterns can be used to solve different challenges:

  1. Creational Patterns like Factory Method and Builder helped us manage the creation of complex objects such as bank accounts and financial reports.

  2. Structural Patterns like Adapter and Decorator allowed us to compose objects and classes into larger structures, helping us integrate third-party payment systems and add new behaviors to our accounts dynamically.

  3. Behavioral Patterns like Observer and Command helped us define clear communication patterns between objects, enabling features like real-time balance updates and undoable transactions.

  4. Architectural Patterns like MVVM and Clean Architecture provided overall structure to our application, separating concerns and making our code more testable and maintainable.

As you continue to develop "Ba Ba Bank" or work on other Flutter projects, keep these patterns in mind, but also remember to:

  • Start with the simplest solution that solves your problem. Don't over-engineer your code with unnecessary patterns.
  • Refactor towards patterns when you see a clear benefit. It's often easier to recognize the need for a pattern as your code evolves.
  • Communicate with your team about the patterns you're using. Shared understanding is crucial for maintaining a consistent codebase.
  • Stay updated with the Flutter community. New patterns and best practices emerge as the framework evolves.

Lastly, remember that design patterns are tools to help you write better code, not rules that must be followed blindly. The best developers know not just how to apply patterns, but when to apply themβ€”and when not to.

As you gain more experience with these patterns in real-world projects, you'll develop an intuition for when and how to apply them effectively. This knowledge will not only make you a better Flutter developer but will also enhance your overall software design skills.

We hope this comprehensive guide to design patterns in Flutter, as illustrated through our "BBB - Ba Ba Bank" application, has been helpful in your journey as a Flutter developer. Happy coding, and may your future Flutter projects be well-structured, maintainable, and successful!

Further Learning

To continue your learning journey with design patterns and Flutter development, consider the following steps:

  1. Practice implementing these patterns in small, focused projects. This will help you understand their nuances and trade-offs.

  2. Join Flutter community forums and discuss pattern usage with other developers. You'll gain insights into real-world applications and challenges.

  3. Contribute to open-source Flutter projects. This will expose you to how patterns are used in larger, collaborative environments.

  4. Stay updated with Flutter's evolution. As the framework grows, new patterns and best practices may emerge.

  5. Explore how these patterns are used in other programming languages and frameworks. This broader perspective will deepen your understanding of software design principles.

Remember, becoming proficient with design patterns is a journey. It takes time, practice, and reflection. But with each pattern you master, you'll become a more effective and versatile developer, capable of tackling increasingly complex challenges in your Flutter projects.

Thank you for joining us on this exploration of design patterns in Flutter through our "BBB - Ba Ba Bank" application. I hope this guide serves as a valuable resource in your development journey. Good luck with your future Flutter projects!

Read this article on Linked In

Top comments (0)