Mobile · Fintech

Why I still choose Flutter for production fintech

Abstract cover: one code shape casting two identical phone silhouettes

I have shipped four money-moving apps on Flutter: a mobile wallet with three roles over one core-banking integration, two cross-border remittance apps, and the payment surface of a commerce SaaS. Between them they have handled real KYC, real fraud rules and real regulators. So when someone asks whether Flutter is "serious enough" for fintech, I am answering from the maintenance window, not the demo.

The short version: the framework has not been the risk. The risk has always sat at the edges — the native integrations, the store review, and the parts of the money flow that are not ours to control.

What actually decides it

A remittance app is mostly forms, state and receipts. Its hard problems are correctness, auditability and network behaviour on a bad connection. None of those are rendering problems, and rendering is where the cross-platform argument used to be won or lost.

What Flutter buys a small team is the thing that matters more than any benchmark: one behaviour to reason about. When a transfer confirmation screen has four states — pending, settled, failed, reversed — I want one implementation of that state machine, not two that drift by a sprint.

Two codebases means two truths about the same money. That is the bug you cannot test your way out of.

Concretely, that state machine is a sealed hierarchy and one transition function, and it exists exactly once:

sealed class TransferState {
  const TransferState();
}

final class Pending  extends TransferState { const Pending(this.ref);      final TransferRef ref; }
final class Settled  extends TransferState { const Settled(this.receipt);  final Receipt receipt; }
final class Failed   extends TransferState { const Failed(this.reason);    final FailureReason reason; }
final class Reversed extends TransferState { const Reversed(this.receipt); final Receipt receipt; }

TransferState apply(TransferState current, RailEvent event) =>
    switch ((current, event)) {
      (Pending(), RailSettled(:final receipt))  => Settled(receipt),
      (Pending(), RailRejected(:final reason))  => Failed(reason),
      (Settled(:final receipt), RailReversed()) => Reversed(receipt),
      // Anything else — including a duplicate webhook — changes nothing.
      _ => current,
    };

The switch is exhaustive over states the compiler knows about, transitions that make no sense are inert instead of improvised, and a duplicate settlement event — which the rails will send you — cannot double-apply. On a two-codebase setup, this exact logic once existed in two subtly different versions for a client I inherited; the iOS one handled the duplicate, the Android one didn't. Flutter's real pitch to fintech is that this class of bug is structurally impossible.

Where I still write native

"Thin platform channel" means the Dart side stays this small — one method, explicit failure, no cleverness:

static const _channel = MethodChannel('app.wallet/biometrics');

Future<BiometricResult> confirmTransfer(TransferRef ref) async {
  try {
    final ok = await _channel.invokeMethod<bool>('authenticate', {
      'reason': 'Confirm transfer ${ref.masked}',
      'allowDeviceCredential': false,
    });
    return ok == true ? BiometricResult.confirmed : BiometricResult.dismissed;
  } on PlatformException catch (e) {
    // Unavailable is a state, not a pass. The transfer screen decides.
    return BiometricResult.unavailable(e.code);
  }
}

The rule embedded in that catch block has survived four apps: a security check that fails must fail loudly into the state machine, never silently succeed and never silently vanish. The native halves — Keychain on one side, Keystore on the other — are each about forty lines, written separately, and reviewed by different people on purpose.

The layout that has held up

Every one of these apps ended up on the same skeleton: MVVM with a repository boundary, an immutable state object per feature, and no business rules in a widget. The rule I enforce in review is simple — a widget may read state and dispatch intent; it may not decide anything about money.

lib/
  core/         // result types, failures, formatters, money
  data/         // api clients, dtos, mappers, secure storage
  domain/       // entities, repositories (abstract), use cases
  features/
    transfer/
      view/         // widgets only
      view_model/   // state + intents
      model/        // feature-local types
  app/          // routing, di, theme
One-directional flow from widgets through view models, use cases and repositories to the banking railswidgetsview modelsuse casesrepositoriesrailsrender + intentstate machinesmoney rulesdto ↔ entitybanks, vaults
Intent flows right, state flows back. Nothing left of the use-case layer is allowed an opinion about money.

The value is not the folder names. It is that a new engineer can find the fee calculation in ninety seconds, and that the fee calculation has a test that does not need a widget tree to run.

Testing the money path

The test pyramid for a money app is unusual: the top is thinner and the bottom is stricter than a normal product's, because the expensive bugs are all in the transitions, not the pixels.

The one that has caught real money bugs, twice, is embarrassingly small:

test('duplicate settlement events cannot double-apply', () {
  final settled = apply(const Pending(ref), RailSettled(receipt));
  expect(apply(settled, RailSettled(receipt)), settled);
});

Both times it fired on a refactor that "couldn't possibly" affect settlement. Idempotency isn't a property you verify once; it's a property you guard, because every future engineer will accidentally attack it.

Numbers from the last release

What I would tell a founder

If your app is a wallet, a remittance flow, an agent tool or a dashboard, Flutter will not be the reason you miss your date. Choose it, then spend the time you saved on the parts that actually decide whether the product survives: reconciliation, idempotency, observability, and a support tool your operations team can use at 2 a.m.

If your app lives or dies on platform-specific capability — deep camera pipelines, CarPlay, widgets everywhere, background location as the product — then be honest about it and budget for native from the start.

Technology should follow the business goal. In fintech, the goal is almost always the same: move money correctly, prove that you did, and be able to fix it fast when you didn't.

All postsReply by email

Keep reading

Next posts