A Few Good CodersStart a project
Guide

Architecture That Survives Year Two of a Product

Year one proves the product. Year two proves the architecture.

For engineering leaders and senior engineers running a mobile or multi-platform product beyond its first release.

Updated , by A Few Good Coders

Published 12 min read12 sectionsGuide

Key takeaways

  • Architecture that lasts keeps change local: dependencies point inward to plain business rules, with UI and infrastructure at the edges.
  • Organise by feature, give every piece of state exactly one owner, and run the design system as a product with its own owner and releases.
  • A test suite survives when it is weighted towards fast unit and widget tests, guards a few critical journeys end to end, and fixes or deletes flaky tests within a week.
  • Flavours, feature flags with expiry dates, staged rollouts and a minimum-version switch let a team ship weekly without betting the business on each build.
  • Performance and accessibility budgets only hold when CI checks them; without numbers, regressions arrive one reasonable pull request at a time.

What year two does to a codebase

An architecture survives year two when a typical change touches one feature, one team and one release, and nobody is nervous about shipping on a Thursday. Year one rarely tests that. Year two does.

In the first year, a small team holds the whole product in their heads, and shortcuts are cheap because the people who took them are still around. By the second year the pressures change:

  • More people. New engineers change code they did not write, without breaking code they cannot see.
  • More features. The product grows sideways: a second user type, a web console, payments, notifications.
  • More versions in the wild. Users do not all update, so the backend serves several app versions at once.
  • Platform churn. Every year brings OS releases, store requirements and framework upgrades that touch every module.
  • People leave. Knowledge that lived in one head leaves with it.

This guide describes the architecture we aim for when a product has to absorb those pressures. The principles are framework-agnostic; the examples use Flutter because many of the mobile products we work on use it, including the shared Flutter foundation behind ScoreUp. If you have not chosen a stack yet, start with our cross-platform decision guide.

Boundaries and dependency direction

Draw boundaries around things that change for different reasons, and make dependencies point one way: from UI and infrastructure towards the business rules, never back. That one rule does more for maintainability than any choice of package.

A practical split has four parts:

  • Domain: entities and business rules in plain Dart (or Kotlin, Swift, TypeScript). No UI framework, no HTTP client, no database.
  • Data: API clients, DTOs (data transfer objects), caching and repository implementations. It implements the domain's interfaces.
  • Presentation: screens, widgets and state holders. It depends on the domain, not on data details.
  • Platform: secure storage, push notifications, deep links and native channels, each behind an interface.

A composition root (in Flutter, usually one main entry point per flavour plus dependency-injection setup) wires real implementations to interfaces. Everything else asks for the interface.

```dart // features/payments/domain/payment_repository.dart // Plain Dart: no Flutter imports, no HTTP. abstract interface class PaymentRepository { Future<List<Payment>> history({required String accountId}); Future<Receipt> pay(PaymentRequest request); }

// features/payments/data/http_payment_repository.dart final class HttpPaymentRepository implements PaymentRepository { HttpPaymentRepository(this._client); final ApiClient _client; // DTO-to-domain mapping lives here, so a backend rename stops in this file. } ```

The payoff is concrete. When the backend renames a field, the change stops at the DTO mapping. When you swap analytics vendors, one adapter changes. When you test a business rule, you need no emulator.

Keep it proportionate. A settings screen does not need four layers and three interfaces. Apply the full split to features with real rules and real risk, such as money, identity and anything regulated, and let simple screens stay simple.

Enforce the direction automatically. A lint rule or a short CI script that fails when a domain/ file imports package:flutter or anything under data/ holds the line better than a wiki page.

Feature modules

Organise code by feature first and by layer second. A feature module owns one area of the product end to end, so a team can change it without reading the rest of the app.

lib/
  app/                 # composition root: routing, DI, flavours, app shell
  core/                # small shared kernel: result types, clock, logging API
  design_system/       # tokens, components, golden tests
  platform/            # push, secure storage, deep links, native channels
  features/
    onboarding/
      domain/          # entities, rules, repository interfaces
      data/            # DTOs, API client, cache, repository implementations
      presentation/    # screens, widgets, state holders
    payments/
      domain/
      data/
      presentation/
test/                  # mirrors lib/
integration_test/      # a few critical journeys, end to end

Three rules keep modules honest:

  1. Features do not import each other's internals. If payments needs the user's profile, it depends on a small interface in core/ or on the profile feature's public API, not on its widgets or DTOs.
  2. The shared kernel stays small. core/ is for things every feature genuinely needs. When it becomes a junk drawer, features couple through it.
  3. Navigation goes through routes, not imports. Features expose typed routes; the app shell composes them.

Extract a feature into its own package (Dart pub workspaces, optionally with a tool such as Melos) when it has a separate owner, a separate release need or a second app that reuses it. Do not extract it because packages look tidy: every package boundary adds versioning and build cost.

Give each module a named owner in a CODEOWNERS file. Ownership is what turns a folder into a boundary.

State ownership

Every piece of state needs exactly one owner, and the owner should match how long the state lives. Many year-two state bugs come down to two components that each believe they own the same thing.

Kind of stateExampleOwnerLifetime
Ephemeral UI stateA text field's value, an expanded panelThe widget or its local controllerWhile the screen is open
Screen stateFilters, pagination, form validationA screen-level state holder (Bloc, Riverpod notifier, view model)While the screen is in the navigation stack
Server stateAccount balance, order historyA repository with a cache and an explicit refresh policyUntil invalidated or stale
Session stateSigned-in user, tokens, entitlementsOne app-level session serviceUntil sign-out or expiry
Durable local stateDrafts, offline queue, preferencesA local store behind a repositoryAcross restarts

Four principles follow:

  • Server state is a cache, not a copy. The backend owns it. Decide per data type when it is stale and how it refreshes, instead of letting each screen fetch and keep its own version.
  • State flows down, events flow up. Screens render state and send intents; state holders decide what changes. This holds in Bloc, Riverpod, Redux, MVI and SwiftUI alike.
  • Pick one primary pattern. The specific state library matters far less than using one consistently. A codebase with three patterns has three kinds of bugs and three onboarding paths.
  • Make loading, empty and error part of the type. A state holder that can only represent "data" ships screens that spin forever when the network fails.

For products that move money, consistent server state is a trust issue. A product like BankSathi, where advisors follow leads, conversions and payouts, needs every screen to agree on the same figure. That is an architecture property, not a UI one.

The design system as a product

A design system survives year two when it is run like a product, with an owner, users, a roadmap and releases. Without that, it becomes a folder of components every team forks.

In practice:

  • Tokens first. Colour, type, spacing, radius, elevation and motion live as named tokens (in Flutter, a ThemeExtension or generated constants), not literals in widgets. A brand refresh becomes a token change.
  • Components with every state. Each component ships with loading, disabled, error, focus and pressed states, its accessibility semantics and its behaviour at large text sizes.
  • A catalogue. A browsable gallery, such as Widgetbook for Flutter or Storybook on the web, lets designers and engineers review components outside the app.
  • Golden tests. Screenshot tests for key components, in light and dark themes and at large text scale, catch visual regressions in review.
  • Adoption is the metric. Track the share of screens built from system components rather than one-off widgets. A falling share means the system is not meeting teams' needs, which is a product signal, not a discipline problem.

Start small: the components on the critical journey, form controls, and feedback patterns such as dialogs, toasts and empty states cover most screens. Grow from repeated real needs, not from a component library nobody has used yet.

A testing pyramid teams actually maintain

The suite that survives is fast, mostly small tests, guards a handful of critical journeys end to end, and is trusted because flaky tests get fixed or deleted. Whether the team believes a green build matters more than any coverage target.

LayerWhat it coversTools in FlutterWhen it runsRough share
UnitDomain rules, mappers, state holderspackage:test, fakes of repository interfacesEvery commit, in secondsMost tests
Widget and goldenScreens and components in isolation, accessibility guidelinesflutter_test, matchesGoldenFileEvery commit, in minutesA solid middle layer
ContractThe app's assumptions about API responsesRecorded fixtures checked against the API schemaEvery commit and on backend changesOne per critical endpoint
End to endSign-in, payment, onboarding and other critical journeys on devicesintegration_test, a device farmNightly and before releaseA handful

The "rough share" column is a starting point, not a standard. The shape is what matters: many fast tests at the bottom, few slow ones at the top.

Habits that keep the pyramid standing:

  • Test behaviour, not wiring. Test that a declined payment shows the right message, not that a method was called twice.
  • Prefer fakes to mocks at boundaries. A fake PaymentRepository that behaves like the real one keeps tests readable and survives refactors.
  • Quarantine flaky tests within a day, then fix or delete them within a week. One flaky test teaches a team to ignore red builds.
  • Add a test with every bug fix. That grows coverage where the risk actually is.
  • Use contract tests to stop silent breakage. A common year-two incident is the app and backend disagreeing about a field; a contract test turns it into a failed build.

Observability

Observability means you can explain what happened to a user from your own data, without asking them for a screen recording. In year two it separates a one-hour fix from a week of guessing.

Instrument four things from the first release:

  1. Crashes and hangs. Crash reporting with symbolicated stack traces (upload iOS dSYMs and Dart symbol files in CI) and app-not-responding (ANR) tracking on Android. As of October 2026, Google Play's Android vitals treat a user-perceived crash rate above 1.09% or ANR rate above 0.47% as bad behaviour that can reduce store visibility; check the current thresholds.
  2. Journey events. A small, named set of events per critical journey: started, step completed, succeeded, failed with a reason code. They turn "payments feel broken" into a funnel you can read.
  3. Structured logs. Levelled logs with a session ID and app version, sampled in production, so a support ticket can be matched to what the app did.
  4. Network health. Latency and error rate per endpoint as the app sees them, split by app version.

Two rules keep it safe and useful:

  • Never log personal or financial data. Redact at the logging boundary, not at each call site. Account numbers, tokens and free text do not belong in a log line.
  • Tag everything with app version and flavour. When a release goes wrong, the first question is always "since which version?"

Then alert on journeys, not only crashes. An app can have a perfect crash-free rate while every payment fails quietly.

Release engineering

Release engineering is architecture you can see: it decides how fast you ship and how much damage a bad build can do. The goal is frequent, boring releases that can be slowed, paused or switched off.

Flavours

Build separate development, staging and production flavours, each with its own app ID, API endpoint, icon and analytics project. In Flutter that means --flavor plus --dart-define-from-file for configuration, so no build is ever edited by hand, and testers can install staging next to production.

Feature flags

Ship code dark, then turn it on. Flags separate deploying code from releasing a feature, which matters on mobile because you cannot roll back a binary already on users' phones.

```dart // One typed entry point; screens never read raw keys. enum Flag { newCheckout, savedCards }

final class Flags { Flags(this._remote); final RemoteConfig _remote; bool isOn(Flag flag) => _remote.getBool(flag.name); } ```

Every flag gets an owner and an expiry date. A release flag still in the code six months after full rollout is dead code with a switch attached.

Staged rollout

Both stores support gradual release. Google Play staged rollouts go to a percentage of users you choose and can be halted. The App Store's phased release spreads automatic updates over seven days and can be paused. Watch crash rate and journey success for the first day or two before widening.

Version compatibility

Old app versions stay in use for months, so two mechanisms matter:

  • Additive API changes. The backend supports every app version above a declared minimum, and fields are removed only after the minimum moves past them.
  • A minimum-version switch. Remote configuration that can prompt or require an update when a version is unsafe. Build it before you need it.

A fixed release train, weekly or fortnightly, beats releasing "when it is ready". Small releases are easier to test, stage and explain.

Performance and accessibility budgets

A budget is a number that fails the build or blocks the release when it is exceeded. Without numbers, performance and accessibility erode one reasonable pull request at a time.

Set budgets for the journeys users care about on the devices they actually own. The targets below are illustrative starting points, not industry standards; measure your own baseline first.

BudgetIllustrative targetHow to check
Cold start to first useful screenUnder 2 s on a mid-range Android phoneStartup traces on a fixed CI device; production startup metrics
Frame time on the heaviest listWithin the frame budget: about 16 ms at 60 Hz, 8 ms at 120 HzFlutter DevTools and profile-mode integration tests
Download sizeNo more than 5% growth per release without a decisionflutter build apk --analyze-size in CI; store size reports
Text scalingCritical journeys usable at 200% text sizeWidget tests with a large TextScaler; manual review
Touch targets and labels48 × 48 dp on Android, 44 × 44 pt on iOS, every control labelledmeetsGuideline checks in widget tests
ContrastWCAG AA: 4.5:1 for body texttextContrastGuideline in widget tests; design review

Flutter makes several of these cheap to automate. flutter_test ships androidTapTargetGuideline, iOSTapTargetGuideline, labeledTapTargetGuideline and textContrastGuideline, which a widget test asserts with expect(tester, meetsGuideline(...)). Put those assertions on every screen of the critical journeys.

Budgets need owners. When one is breached, either fix the regression or raise the budget deliberately with a written reason. Silently exceeding it is the only wrong answer.

Signs your architecture is failing, and what to do

An architecture is failing when the cost of change keeps rising for reasons unrelated to the change itself. The symptoms appear in delivery data and in how the team talks long before anyone says "rewrite".

SymptomLikely causeFirst move
Small features touch many files across many foldersOrganised by layer, not feature; leaky boundariesMove the most-changed feature into a module with a public API
The same bug returns on another screenDuplicated rules, or state with two ownersMove the rule into the domain; give the state one owner
Every release needs a "stabilisation week"Weak tests on critical journeys; no staged rolloutAdd end-to-end tests for the top three journeys; stage every release
Everyone avoids one area of the codeNo owner and no tests around a high-risk areaAssign an owner, add characterisation tests, then refactor
Framework upgrades take weeksPlugin and framework types leak into business logicWrap plugins behind interfaces as you touch them
Production questions take days to answerNo journey events or version-tagged logsInstrument one critical journey end to end this sprint

What to do on Monday:

  1. Measure before you reorganise. Pull files changed per pull request, time from merge to release and the top crash clusters for the last quarter, and pick the feature where they look worst.
  2. Refactor where you are already working. Improve the boundary around the feature on this quarter's roadmap, not the one nobody touches.
  3. Write decisions down. A short architecture decision record per major choice (state pattern, module rules, flag policy) saves the next engineer weeks.
  4. Strangle, do not rewrite. Replace one module at a time behind its interface while the product keeps shipping. Reserve a full rewrite for when the platform itself must change.

If you have inherited a product in this state, our guide to taking over a codebase covers the first weeks. If you want help, start with a call: we review the architecture and release path before recommending anything larger, and hand over a codebase your team can own. Our Flutter app development page explains how that work runs.

Questions

What is the best architecture for a Flutter app?

There is no single best pattern, but durable Flutter apps share three traits: code organised by feature, dependencies pointing inward to plain-Dart business rules, and one consistent state-management approach. Which library you use matters less than applying it consistently and keeping UI, data and domain concerns separate.

Is clean architecture overkill for a small app?

Full layering on every screen can be. Apply the domain, data and presentation split to features with real rules and risk, such as payments, identity or regulated flows, and let simple screens stay simple. The rule worth keeping everywhere is dependency direction: UI and infrastructure depend on business rules, never the reverse.

Should we use Bloc or Riverpod?

Both are mature, widely used and support testable, well-structured apps. Choose the one your team understands best, document the conventions and use it everywhere. Mixing several state approaches in one codebase usually costs more than either choice would.

How many tests does a mobile app need?

Enough that the team trusts a green build. In practice that means many fast unit and widget tests, contract tests for critical API endpoints, and a handful of end-to-end tests for journeys such as sign-in and payment. Adding a test with every bug fix grows coverage where the risk is.

When should we split a Flutter app into packages?

When a feature has a separate owner, a separate release need or a second app that reuses it. Until then, feature folders with enforced import rules give most of the benefit at lower cost, because every package boundary adds versioning, build configuration and review overhead.

When is rewriting an app justified?

Rarely. A rewrite pauses the roadmap and tends to reintroduce bugs the old code had already fixed. Prefer replacing one module at a time behind clear interfaces while the product keeps shipping, and reserve a full rewrite for when the platform or stack itself has to change.