Also in 中文. The closing part of Trading app architecture.
The preceding articles each argue a mechanism. This one commits to a design: a market-data app with search, watchlists, and charts, built by two or three people, with trading added later.
Committing matters. It’s easy to write “it depends”; the useful artifact is a specific plan, the reasoning that produced it, and an explicit account of what it refuses to build.
The constraints, stated honestly
Before choosing anything, the six magnitudes from the overview, answered for this product:
| Constraint | Our magnitude |
|---|---|
| Freshness | 1-second quotes, a few hundred visible symbols |
| Irreversibility | none in v1 — read-only |
| Catalog | ~150k instruments, searchable offline |
| Platforms | iOS + Android, one small team |
| Teams | one |
| Threat | public market data; user accounts in v2 |
Five of six are small. That decides most of the architecture, and it decides it in the direction of less.
The design
WebSocket ──▶ decode (Dart) ──▶ FrameCoalescer ──▶ QuoteStore ──▶ leaf widgets
│
REST ──▶ catalog delta sync ──▶ SQLite (bundled snapshot) ──▶ search
Transport. One WebSocket for live quotes, REST for everything else. JSON at this rate is fine; a binary codec is a later optimization, not a starting decision.
Ingest. Decode on the UI isolate to begin with. At a few hundred messages a second this measures in microseconds. Move it to an isolate the day the profile says so — not before.
Conflation. The FrameCoalescer from the state article sits between the socket and the store. This one is worth building on day one even at low rates: it is thirty lines, and it makes the render cost independent of the feed rate permanently.
State. Per-symbol ValueNotifiers in a QuoteStore; leaves subscribe individually. Page-level state can be whatever you like — the granularity discipline only matters on the hot path.
Catalog. A prebuilt SQLite file shipped as an asset, copied on first run, delta-synced by version, searched via FTS with pinyin and initials columns, per the offline article.
Charts. CustomPainter with a static layer and a live layer in separate repaint boundaries. Use a charting package first if it fits; write your own when you need indicator panes or drawing tools.
Structure. One package with clear folders. Not a monorepo — see below.
What we deliberately do not build
This list is the actual content of the design. Anything can be added; the discipline is in the sequencing.
- No native code, no FFI. A 1 Hz feed does not justify a boundary that costs you stack traces and two toolchains.
- No custom binary protocol. JSON over WebSocket until payload size or parse time is measurably a problem.
- No shared cross-platform core. One Flutter codebase already covers both platforms; a shared domain layer solves a problem we don’t have.
- No package-per-feature monorepo. One team, one app: package boundaries would be overhead, not enforcement.
- No add-to-app. Nothing to embed into.
- No certificate pinning. Public data, and pinning is an operational commitment we cannot yet honor. Standard TLS, done properly.
- No order state machine. No orders. When they arrive, that article becomes mandatory reading, not optional.
Every line is a decision we can reverse. That is the point of writing them down: someone will propose each one, and the answer should be “not yet, and here’s what would change my mind.”
Build order
Phase 1 — catalog and search. Ship the snapshot, build search, list instruments. No live data yet. This proves the largest, least reversible asset (the database and its sync) before anything depends on it.
Phase 2 — live quotes. WebSocket, coalescer, per-symbol notifiers, watchlist screen. Now the reactive path exists end to end. Reconnect with backoff and resubscribe from the start; retrofitting reconnection is painful because it touches every subscription site.
Phase 3 — charts. History over REST, custom painter, two layers. The first screen where raster-thread performance matters, and the first place you profile on a mid-range device.
Phase 4 — accounts and orders. Auth, then the full order reliability design: idempotency keys persisted before send, an unknown state, reconciliation on reconnect. This phase changes the threat model, so the security article applies from its first commit.
Phases 1-3 have no irreversible actions, which is why they can move fast. Phase 4 is where care stops being optional, and it deserves a slower cadence.
The upgrade path
The design’s real quality is not what it does now but where it can grow without a rewrite. Each heavy element has a defined seam and a measured trigger:
| Element | Where it plugs in | Adopt when |
|---|---|---|
| Isolate for decode | behind the feed interface | UI-thread profile shows decode in frame budget |
| Binary protocol | codec inside the feed | payload size or parse time measurably hurts |
| Native/FFI ingest | replaces the feed implementation | conflation in Dart can’t keep up — likely never at this rate |
| Feature packages | extract from folders | two people repeatedly collide, or a layer needs isolated tests |
| Shared domain core | extract domain/ | a second client appears that must not diverge |
| Order machinery | new module | the first irreversible action ships |
The seams are what you design deliberately today: a feed exposed as an interface rather than a concrete socket class, catalog access behind a repository, and business rules kept out of widgets. None of those cost anything now, and each one is what makes a later change an extraction instead of a rewrite.
The one thing to keep
If a reader takes a single idea from this series: write down your six magnitudes before you choose anything, then adopt each heavy element on the day a measurement demands it.
Large architectures are not more advanced. They are the same constraints at full size, plus years of accumulated migration state. Copying one early means paying its coordination costs with none of its scale — which is the most common and most expensive mistake in this whole subject.
References
API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).
- M. Fowler, “Yagni”, 2015 — the cost of a presumptive feature is not only building it but carrying it.
- D. McKinley, “Choose Boring Technology”, 2015 — innovation tokens, and why JSON over a WebSocket is the right first answer.
- J. Bezos, Amazon 2015 Letter to Shareholders, 2016 — Type 1 (one-way door) vs Type 2 (two-way door) decisions.
Previous: Flutter project structure · Back to the overview.