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.
What the engagement involved
- Industry
- Professional Services
- Duration
- 20 weeks
- Cooperation model
- Time and materials
Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.
What was there, and what replaced it
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.
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.
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
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.
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.
- 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
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.
One feature, one folder
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.
- Bindings, controllers and models scoped per feature folder
- A feature is understandable without the whole app in context
- Dependencies declared at the module boundary
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.
- Retries, refresh, error mapping and logging in interceptors
- One decision about a 401, inherited everywhere
- A network policy change is a single edit
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.
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.
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.
- No behavioral change, so the work is judged on one axis
- Same screens, flows and copy throughout
- Behavior differences treated as defects, not improvements
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.
- Feature-by-feature migration with both architectures coexisting
- The app stayed releasable at every point
- No big-bang cutover to be canceled halfway
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.
Phase by phase
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
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
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
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
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
- 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.
- 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.
- 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.
- 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
- ScreenFeature module
- LogicService
- DomainRepository
- TransportData sourcePOST /invoices/INV-0404/reminders
The request lives in the data source, and nowhere above it.
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
Reason to change
What the screen shows or lets you do. Its binding declares the service it needs at the module boundary.
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.
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.
- 01Feature 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.
- 02ServiceThe logicLogic lives in services, out of the screens. Splitting into modules showed a third of what looked feature-specific belonged here.
- 03RepositoryDomain 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.
- 04Data sourceDio, with interceptorsRetries, token refresh, error mapping and logging are decided once in interceptors. One answer to a 401, inherited by every call.
- 05APIThe 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 buildNearby engagements
AI & AutomationA private legal assistant grounded in verified precedents
A private knowledge assistant that searches internal case files and precedents, providing cited answers legal teams can verify in seconds.
Legal & Law Firms · 14 weeks
Product DesignAn onboarding flow that guides trial users to value
A redesigned SaaS trial onboarding experience with progressive checklists, sample data, and inline guidance that turns signups into active subscribers.
Professional Services · 10 weeks
Product DesignA design system that brought speed and consistency to 4 product teams
A token-based design system in Figma and React that eliminated component duplication across 4 product squads and cut the time from design handoff to merged frontend.
Professional Services · 14 weeks
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.














