Skip to content

The same app, rebuilt, and the second one could be changed without fear

A Flutter finance app for small businesses, rebuilt from a flat structure into real layers (data sources, repositories, services and feature modules) with the same screens and a very different maintenance cost.

10:42
KeelbookWrenfold Joinery Ltd · Thu 17 SepWJ
Cash in the bank£44,642.37Card owed£1,284.60

Three accounts

Banking
Business current ••441810 lines this month£18,642.37
Tax & VAT savings ••7730+£1,000.00 on 15 Sep£26,000.00
Business card ••0921Statement due 28 Sep−£1,284.60
Owed to you3 overdue£20,946.00
VAT to payby 7 Oct£5,771.64

This week

in £1,152.00 · out £2,052.84
Northmoor Timber 17 Sep−£1,588.44
Pemberton Café · INV-0410 16 Sep+£1,152.00
Harlow Fuel 16 Sep−£68.40
Current → savings 15 Sep£1,000.00
Fleetway Van Hire 14 Sep−£396.00
OverviewBankingInvoicesExpensesVAT

What the engagement involved

Industry
Professional Services
Duration
20 weeks
Cooperation model
Time and materials
Services
Mobile engineeringArchitectureMigration
Integrations
Existing REST APIBank statement importCrash reportingApp Store & Play Console
Technologies
FlutterDartGetXDioRESTSecure Storage
Team
1 Project lead2 Mobile engineers1 QA engineer

Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.

Before and after

What was there, and what replaced it

What was there

The first version worked. Models sat beside main, screens called the network directly, and every feature knew about transport. Nothing was wrong with what it did. The problem was that changing any of it meant reading all of it, so the roadmap had quietly stopped.

The absence of a boundary was the whole fault. Because screens called Dio directly, each had its own opinion about what to do on a 401: three different behaviors in three parts of the app, none of them documented. A changed API field meant a search across the feature set instead of an edit, and nobody could tell from a diff whether every site had been found.

What replaced it

The same product on explicit layers: data sources that speak HTTP, repositories that speak the domain, services that hold logic, and feature modules with their own bindings and controllers. Screens no longer know a network exists.

Introduction

What we inherited

The finance app did the right things and couldn't be changed safely. We rebuilt it around explicit layers, keeping the product identical while making the next change cheap.

A finance app that worked and couldn't be changed. Models sat beside application entry points, screens called the network directly, and every feature knew about transport. Nothing it did was wrong; the problem was that any change required reading all of it, so estimates had inflated to the point where the roadmap had quietly stopped being planned.

Mobile Architecture & Engineering

The solution

What we kept, and what we cut

  • Kept the product identical so the rebuild could be judged on maintainability alone

    The product was held identical (same screens, flows and copy), so the rebuild could be judged on what it costs to change the app afterward and nothing else.

  • Put a repository between every screen and the network, without exception

    The rule admitted no exceptions, which mattered: the two screens argued for as special cases were the ones that had accumulated the worst transport code.

  • Gave each feature its own module with bindings, controllers and models

    Splitting into modules revealed how much was genuinely shared: a third of what looked feature-specific turned out to belong in services.

  • Centralized HTTP so retries, auth and errors are decided once

    Retries, token refresh, error mapping and logging live in interceptors, so there's one decision about a 401 instead of three undocumented ones.

  • Migrated feature by feature so the app stayed shippable throughout

    Feature-by-feature meant both architectures coexisted for four months. That's uncomfortable, and it's what kept the app releasable throughout.

01

A repository between every screen and the network

No exceptions, which is what makes the rule worth having.

Every screen reads through a repository and never touches Dio directly. No exceptions, which is the only thing that makes the rule worth stating. Repositories speak the domain in domain types, data sources speak HTTP in wire types, and the mapping between them happens in one place, so a changed API field is one edit instead of a search across the feature set.

What shipped
  • No screen touches HTTP; the rule has no exceptions
  • Domain types above, wire types below, mapping in one place
  • An API field change is one edit, not a search
10:42
Business current ••4418Statement · 1–17 Sep 2026
Money in£12,036.00Money out−£10,359.80Net+£1,676.20
DateDetailsAmountBalance
01 SepBrought forward£16,966.17
02 SepCastleford InteriorsINV-0405+£3,960.00£20,926.17
04 SepTimberline MerchantsCard ••4418−£842.16£20,084.01
05 SepPayroll, 3 staffBACS−£6,318.40£13,765.61
08 SepAshby & Rowe ArchitectsINV-0407+£2,184.00£15,949.61
11 SepBrassworks HardwareCard ••4418−£214.80£15,734.81
12 SepHolloway LettingsINV-0409+£4,740.00£20,474.81
14 SepFleetway Van HireDD−£396.00£20,078.81
15 SepTo savings ••7730Transfer−£1,000.00£19,078.81
16 SepPemberton CaféINV-0410+£1,152.00£20,230.81
17 SepNorthmoor TimberFPS−£1,588.44£18,642.37
17 SepCarried forward£18,642.37
Brought forward plus every line equals the bank’s closing balance
OverviewBankingInvoicesExpensesVAT
On screen

A bank statement for the current account: brought forward, ten lines with the running balance carried down every one, and a carried-forward figure that is the opening balance plus every line to the penny.

10:42
ExpensesSeptember to date · 6 expenses
2 receipts still missing this monthAttach

Spend by supplier

Sep

£3,151.80

Northmoor Timber£1,588.44
Timberline Merchants£842.16
Fleetway Van Hire£396.00
Brassworks Hardware£214.80
Harlow Fuel£68.40
Parcelwise Couriers£42.00

Recent

Northmoor Timber 17 Sep · Materials£1,588.44Receipt
Harlow Fuel 16 Sep · Motor, fuel£68.40No receipt
Parcelwise Couriers 15 Sep · Postage£42.00Receipt
Fleetway Van Hire 14 Sep · Motor, van hire£396.00No receipt
Brassworks Hardware 11 Sep · Materials£214.80Receipt
Timberline Merchants 04 Sep · Materials£842.16Receipt
OverviewBankingInvoicesExpensesVAT
02

One feature, one folder

On screen

Expenses for September to date: the month's spend by supplier, the recent expenses with the account each came from, and the two whose receipts are still missing.

Bindings, controllers and models per module, so a feature is understandable on its own.

Each feature owns a folder with its own bindings, controllers and models, so it can be read, reasoned about and changed without holding the rest of the app in your head. GetX bindings are declared per module, which means a feature's dependencies are visible at its boundary instead of assembled globally and hoped for.

What shipped
  • Bindings, controllers and models scoped per feature folder
  • A feature is understandable without the whole app in context
  • Dependencies declared at the module boundary
03

HTTP decided once

Retries, auth and error handling live in one place, out of every call site.

Retries, auth token refresh, error mapping and logging live in Dio interceptors, decided once. Previously each call site had its own opinion about what to do on a 401, which produced three different behaviors in three parts of the app. Now a network policy change is a single edit, and every call inherits it whether or not anyone remembers it exists.

What shipped
  • Retries, refresh, error mapping and logging in interceptors
  • One decision about a 401, inherited everywhere
  • A network policy change is a single edit
10:42
INV-0404Greyfriars BakeryOverdue · 10 days
accounts@greyfriarsbakery.co.ukIssued24 Aug 2026Terms14 daysDue07 Sep 2026

Lines

Oak serving counter, fitted1 × £1,850.00£1,850.00
Shelving bays, ash3 × £240.00£720.00
Site survey and visit1 × £310.00£310.00
Net£2,880.00
VAT at 20%£576.00
Total£3,456.00

Reminders

2 sent
First reminder08 Sep 2026, 09:00 · emailedSent
Second reminder14 Sep 2026, 09:00 · emailedSent
Paid £0.00 · balance due£3,456.00
Record paymentSend reminder
On screen

One overdue invoice: its three lines, net, VAT at 20% and total, the two reminders already emailed, the balance still due, and the button that sends the next one.

10:42
VAT returnJun – Aug 2026 · due 07 Oct 2026Draft
1VAT due on sales£9,184.20
2VAT due on NI acquisitions from EU£0.00
3Total VAT due1 + 2£9,184.20
4VAT reclaimed on purchases£3,412.56
5Net VAT to pay3 − 4£5,771.64
6Sales, excluding VAT£45,921
7Purchases, excluding VAT£18,240
8NI supplies to EU, ex VAT£0
9NI acquisitions from EU, ex VAT£0

Before it can be submitted

5 of 6
Box 3 equals box 1 plus box 2
Box 5 equals box 3 minus box 4
Boxes 6 to 9 in whole pounds
Every bank line in the period matched
No expense in the period without a receipt
Declaration confirmed
Net VAT to pay£5,771.64 Confirm declaration
OverviewBankingInvoicesExpensesVAT
On screen

The VAT return for June to August by box, with boxes 3 and 5 derived from the others, and the checks that run before it can be submitted: five passed, the declaration still to confirm.

04

Identical product

The rebuild changed no behavior, so it could be judged purely on what it cost to change afterward.

The rebuild deliberately changed no behavior. Same screens, same flows, same copy, so it could be judged on exactly one axis: what it costs to change the app afterward. No redesign was smuggled in alongside to muddy the verdict. Any behavioral difference found during migration was treated as a defect, not an improvement.

What shipped
  • No behavioral change, so the work is judged on one axis
  • Same screens, flows and copy throughout
  • Behavior differences treated as defects, not improvements
05

Shippable throughout

Feature-by-feature migration meant the app was always releasable and never became a parallel product.

Migration went feature by feature, with old and new architecture coexisting behind the same navigation, so the app was releasable at every point and never became a parallel product waiting for a big-bang cutover. That's the pattern that decides whether a rewrite ships: the ones that stop being releasable are the ones that get canceled.

What shipped
  • Feature-by-feature migration with both architectures coexisting
  • The app stayed releasable at every point
  • No big-bang cutover to be canceled halfway
10:42
Invoices8 open · £20,946.00 owed to you
Open 8PaidDrafts

Overdue · 3

£7,848.00
Marlowe Dental PracticeINV-0398· due 21 Aug· 3£2,880.0027 days late
Lindqvist OpticiansINV-0402· due 31 Aug· 2£1,512.0017 days late
Greyfriars BakeryINV-0404· due 07 Sep· 2£3,456.0010 days late

Due · 5

£13,098.00
Ashby & Rowe ArchitectsINV-0406· due 21 Sep£4,296.00in 4 days
Holloway LettingsINV-0408· due 25 Sep£960.00in 8 days
Pemberton CaféINV-0411· due 01 Oct£738.00in 14 days
Quayside Brewing Co.INV-0412· due 08 Oct£5,160.00in 21 days
Wickham Parish HallINV-0413· due 15 Oct£1,944.00in 28 days
OverviewBankingInvoicesExpensesVAT
On screen

Open invoices, with the three overdue ones held in their own group above the five that are merely due, each showing its due date, amount and the reminders sent so far.

Process

Phase by phase

  1. Phase 1: The Seam

    Finding Where To Cut

    Mapped every place a screen touched transport. That set became the repository boundary, and it was the only structural decision that mattered.

    • Dependency Map
    • Repository Boundary
    • Migration Order
  2. Phase 2: The Data Layer

    Sources, Repositories, Services

    Built data sources that speak HTTP, repositories that speak the domain, and services holding the logic between them, so each layer has one reason to change.

    • Data Sources
    • Repositories
    • Domain Services
  3. Phase 3: Feature Modules

    One Feature, One Folder

    Split the app into modules with their own bindings, controllers and models, so a feature can be understood, and handed over, without reading the rest.

    • Feature Modules
    • Bindings
    • Controllers
  4. Phase 4: Migrating Live

    Shippable The Whole Way

    Moved feature by feature instead of rewriting wholesale, so the app was releasable at every point and the rebuild never became a second product.

    • Incremental Migration
    • Parity Checks
    • Release Cadence
10:42
New expenseUnsaved · receipt still to attach
No receipt yetThe expense saves without one and stays on the missing list. Take photo Choose file
SupplierFleetway Van Hire
Date14 Sep 2026
CategoryMotor, van hire
Paid from Business current ••4418
Total paid£396.00
VAT20%£66.00
Net£330.00
NoteTransit, 2 days, Northmoor pickup
Save draftSave expense
On screen

Recording the van hire: supplier, date, category and the account it was paid from, the total split into net and VAT at 20%, and no receipt yet, so it stays on the missing list.

After the rebuild

None

Behaviour changed

0

Screens touching transport

Continuous

Releases during migration

4

Layers with one reason to change

Behavior changed is none, which was the constraint, not an outcome. Screens touching transport is zero. Releases during migration is a statement about the process: the app shipped continuously and never went dark. Four layers with one reason to change describes the resulting structure.

Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.

What the rebuild taught us

  1. 01

    An app that works but can't be changed safely has already stopped having a roadmap.

    An app nobody dares change has already lost its roadmap; the estimates inflate first and the planning stops quietly afterwards.

  2. 02

    The repository boundary is worth more than the state-management library argument.

    The repository boundary did more than any state-management decision would have. It's what makes a screen readable without knowing where data comes from.

  3. 03

    A rebuild that also changes behavior can't be judged, because two variables moved.

    Changing behavior during a rebuild moves two variables at once, and afterward nobody can say which one produced the outcome.

  4. 04

    Migrating feature by feature keeps a rebuild from quietly becoming a second product.

    Feature-by-feature migration is what keeps a rebuild from becoming a parallel product waiting on a cutover that gets cancelled.

How we worked alongside the team

We kept the product identical on purpose. A rebuild that also changes behavior moves two variables and can't be judged, so parity was the acceptance criterion for every migrated feature, and the app stayed releasable the whole way through instead of becoming a parallel product.

Time and materials over twenty weeks, with an explicit rule that no behavior would change: same screens, same flows, same copy. Any behavioral difference found during migration was treated as a defect, never an improvement, so the rebuild could be judged on exactly one axis. Migration went feature by feature with both architectures coexisting, so the app was releasable throughout.

One tap, four layers

The same reminder goes out. The request just lives somewhere else now.

Follow “Send reminder” on an overdue invoice from the screen down to the network, in the app before the rebuild and after it. Before, the HTTP call sat in the screen beside the widgets; after, it sits in a data source three layers further down, and what the customer sees hasn't moved. Pick a layer, switch versions, or trace the tap.

Send reminder · INV-0404

  1. ScreenFeature module
  2. LogicService
  3. DomainRepository
  4. TransportData source
    POST /invoices/INV-0404/reminders

The request lives in the data source, and nowhere above it.

modules/invoices/invoice_detail_controller.dartAfter · Feature module
class InvoiceDetailController extends GetxController {  InvoiceDetailController(this._reminders);  final ReminderService _reminders;  final invoice = Rxn<Invoice>();   Future<void> sendReminder() async {    invoice.value = await _reminders.send(invoice.value!);  }}

Knows about

WidgetsController stateReminderServiceInvoice

Reason to change

What the screen shows or lets you do. Its binding declares the service it needs at the module boundary.

What the customer sees after the rebuildThird reminder emailed to accounts@greyfriarsbakery.co.uk · INV-0404 stays overdue · £3,456.00 dueIdentical in both

Illustrative code. Class names and the endpoint are invented; the structure is the study’s. The trace’s pace is for reading, not a measured timing.

Architecture

Four layers between a tap and the network, each with one reason to change

Flutter and Dart, GetX for bindings and controllers, Dio over REST, and secure storage on the device. The screens customers already had stayed as they were; everything beneath them was rebuilt, feature by feature.

  1. 01
    Feature moduleScreen, controller, bindingEach feature owns its folder of bindings, controllers and models. Dependencies are declared at the module boundary, and no screen knows a network exists.
  2. 02
    ServiceThe logicLogic lives in services, out of the screens. Splitting into modules showed a third of what looked feature-specific belonged here.
  3. 03
    RepositoryDomain typesEvery screen reads through a repository, without exception. Domain types above, wire types below, mapped in one place, so a changed API field is one edit.
  4. 04
    Data sourceDio, with interceptorsRetries, token refresh, error mapping and logging are decided once in interceptors. One answer to a 401, inherited by every call.
  5. 05
    APIThe REST APISame screens, same flows, same copy on top of it. Any behavioral difference found while migrating was fixed as a defect, not kept as an improvement.

Same numbers, no freeze, one network policy

Rebuilding a finance app without moving what customers check

The figures weren't up for redesign

The rule was no behavior change: same screens, same flows, same copy. Parity checks ran through the migration, and any difference found, whether in a balance, a total or a VAT box, was treated as a defect.

Releases never waited on the rebuild

Migration went feature by feature, with the old and new architecture coexisting behind the same navigation for four months. The app was releasable at every point, with no big-bang cutover waiting to be cancelled halfway.

One answer to an expired session

Before, three parts of the app handled a 401 in three undocumented ways. Token refresh, retries and error mapping now sit in one set of interceptors, so every call, including ones added later, inherits the same decision.

Running an app that works but nobody dares change? Scope your build in 3 minutes.

Scope your build
Have a project?

Let's talk

Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.