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
- Device security. Biometric prompts, keystore and secure enclave access, root/jailbreak signals — thin platform channels, reviewed carefully, written twice on purpose.
- SDKs the vendor only ships native. Some banking and telco SDKs never leave their own platform. Wrap them; do not fight them.
- Background behaviour. Push-triggered balance refresh and transaction listeners behave differently enough per platform that pretending otherwise costs you a release.
"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
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.
- Transition tests on the state machine, exhaustively, including the hostile cases — duplicate webhooks, out-of-order events, a reversal arriving before its settlement. These are pure functions; there are hundreds and they run in a blink.
- Use-case tests with faked rails: the fee, limit and FX logic against a bank that fails in every way the real one has, plus two ways it hasn't yet.
- A handful of widget tests that only assert the state→pixel contract: a
Reversedstate must always show the reversal receipt and never a retry button.
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
- One codebase, two stores, ~92% shared code — the remainder is security and vendor SDK glue.
- Cold start on a low-end Android device: 1.4s to first interactive screen after we deferred non-critical providers.
- Crash-free sessions: 99.7% over 30 days, with the remaining tail almost entirely in one vendor SDK.
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.