<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Architecture | kaguc — Writing to understand systems.</title><link>https://kaguc.com/tag/architecture/</link><atom:link href="https://kaguc.com/tag/architecture/index.xml" rel="self" type="application/rss+xml"/><description>Architecture</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Sat, 29 Aug 2026 00:00:00 +0000</lastBuildDate><image><url>https://kaguc.com/media/logo.svg</url><title>Architecture</title><link>https://kaguc.com/tag/architecture/</link></image><item><title>When to adopt the heavy machinery: thresholds, not opinions</title><link>https://kaguc.com/blog/adoption-thresholds/</link><pubDate>Sat, 29 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/adoption-thresholds/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/adoption-thresholds-zh/">中文&lt;/a>. The decision rules for &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>.&lt;/em>&lt;/p>
&lt;p>Every article in this series ends with some version of &amp;ldquo;adopt this when a measurement demands it.&amp;rdquo; That advice is worthless as stated. It names no metric, no threshold, and no device, which means in practice the decision gets made by whoever argues most confidently in the meeting.&lt;/p>
&lt;p>So here are the numbers. Some are arithmetic and hold everywhere. Some are perception research. Some are conventions I&amp;rsquo;ll label as conventions. Calibrate them to your product — but calibrate &lt;em>numbers&lt;/em>, not vibes.&lt;/p>
&lt;p>Before that, the correction that matters more than any threshold.&lt;/p>
&lt;h2 id="not-everything-waits-for-a-threshold">Not everything waits for a threshold&lt;/h2>
&lt;p>The series implies one decision rule for everything. That is wrong, and it&amp;rsquo;s the more dangerous half of the mistake, because &amp;ldquo;we&amp;rsquo;ll add it when we need it&amp;rdquo; applied to the wrong category produces a rewrite or a loss.&lt;/p>
&lt;p>Sort every candidate into one of two boxes.&lt;/p>
&lt;p>&lt;strong>Box A — cost of waiting is bounded.&lt;/strong> You can add it later at roughly the cost of adding it now. Performance work is almost entirely here: an isolate, a binary codec, native ingest, custom painting, feature packages. If you skip it and you were wrong, the symptom is &lt;em>slowness&lt;/em>, which is visible, measurable, and fixable next sprint.&lt;/p>
&lt;p>&lt;strong>Box B — cost of waiting is unbounded.&lt;/strong> Retrofitting means touching every call site, or the damage has already happened silently by the time you notice. These get built on day one at any scale, and there is no threshold discussion:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Idempotency keys on irreversible actions&lt;/strong>, persisted before the first send. Retrofit means auditing every mutation path, and until you do, duplicates are already reaching production.&lt;/li>
&lt;li>&lt;strong>Server-side authorization for every request.&lt;/strong> A client-trusting endpoint is a breach that exists from the day it ships.&lt;/li>
&lt;li>&lt;strong>Sequence numbers on any stream where a gap corrupts state.&lt;/strong> Without them you cannot even &lt;em>detect&lt;/em> the corruption retroactively.&lt;/li>
&lt;li>&lt;strong>Reconnect and resubscribe.&lt;/strong> Bolting this on later touches every subscription site, and its absence looks exactly like a working app.&lt;/li>
&lt;li>&lt;strong>Decimal, not binary floating point, for money.&lt;/strong> &lt;code>0.1 + 0.2 != 0.3&lt;/code> is a rounding error in a ledger. Migrating a persisted schema off floats is brutal.&lt;/li>
&lt;li>&lt;strong>An append-only event log for financial actions.&lt;/strong> You cannot reconstruct history you never recorded.&lt;/li>
&lt;/ul>
&lt;p>The test for Box B: &lt;strong>if this is missing, is the failure silent, or does it corrupt data, or does it require touching every call site to add?&lt;/strong> Any yes means day one.&lt;/p>
&lt;p>Everything below is about Box A only. That distinction is the actual answer to &amp;ldquo;when do I use the heavy stuff.&amp;rdquo;&lt;/p>
&lt;h2 id="first-define-the-target">First, define the target&lt;/h2>
&lt;p>A threshold needs three things to be meaningful, and most teams state none of them:&lt;/p>
&lt;p>&lt;strong>A metric with a percentile.&lt;/strong> Averages hide exactly the problem you care about. One frame in twenty taking 40 ms is invisible in a mean and clearly visible to a human. Use p95 and p99.&lt;/p>
&lt;p>&lt;strong>A reference device.&lt;/strong> &amp;ldquo;Fast on my phone&amp;rdquo; is not data. Pick a specific mid-range device roughly three years old and make it the gate. A flagship will pass tests that a large share of your users would fail.&lt;/p>
&lt;p>&lt;strong>A realistic load.&lt;/strong> Production message rate, production catalog size, and a session at least 30 minutes long — leaks and cache growth are invisible in a two-minute test.&lt;/p>
&lt;p>Written out, a usable target looks like: &lt;em>p95 UI frame under 16 ms, and p99 under 24 ms, with 200 visible symbols at the production feed rate, on the reference device, after 30 minutes.&lt;/em>&lt;/p>
&lt;p>Now a threshold can be violated, which is what makes it a decision rule instead of an opinion.&lt;/p>
&lt;h2 id="the-frame-budget-which-is-arithmetic">The frame budget, which is arithmetic&lt;/h2>
&lt;p>At 60 Hz you have &lt;strong>16.67 ms&lt;/strong> per frame for everything; at 120 Hz, &lt;strong>8.33 ms&lt;/strong>. Two threads must both fit: the UI thread (your Dart, build, layout) and the raster thread (painting into pixels).&lt;/p>
&lt;p>Budget roughly &lt;strong>half the frame for UI-thread work&lt;/strong> — so about 8 ms at 60 Hz. The rest is layout, paint, raster, and headroom for the spikes that any real system has. If your Dart work routinely exceeds 8 ms per frame, you are one hiccup from a dropped frame, and dropped frames cluster exactly when the app is busiest.&lt;/p>
&lt;p>This is why the numbers below are expressed in milliseconds per frame. It&amp;rsquo;s the only budget that exists.&lt;/p>
&lt;h2 id="thresholds-element-by-element">Thresholds, element by element&lt;/h2>
&lt;p>&lt;strong>Move decoding to an isolate&lt;/strong> when parsing occupies &lt;strong>more than 2–3 ms per frame at p95&lt;/strong>, sustained, in the DevTools timeline.&lt;/p>
&lt;p>Below that it&amp;rsquo;s noise. Above it you&amp;rsquo;re spending a third of your UI budget on parsing. But apply the cheaper fixes first (see the ladder below) — and note that messages sent between isolates are &lt;em>copied&lt;/em>, so a chatty isolate can be slower than no isolate at all. Send batches, use &lt;code>TransferableTypedData&lt;/code> to move bytes without copying, or use &lt;code>Isolate.run&lt;/code>, which transfers its result back rather than copying it.&lt;/p>
&lt;p>&lt;strong>Switch to a binary protocol&lt;/strong> when any of: JSON parsing still exceeds ~2 ms per frame &lt;em>after&lt;/em> conflation; sustained payload exceeds ~100 KB/s on mobile data; or message rate exceeds roughly 1,000/s.&lt;/p>
&lt;p>Under those, JSON&amp;rsquo;s cost is real but not your bottleneck, and it buys you debuggability that a binary format takes away.&lt;/p>
&lt;p>&lt;strong>Go native (FFI)&lt;/strong> when Dart cannot meet the budget &lt;em>after&lt;/em> isolate and conflation — realistically above &lt;strong>~10,000 messages/second&lt;/strong>, or for numeric work over large arrays such as computing indicators across 100k+ bars where you need sub-millisecond determinism.&lt;/p>
&lt;p>An honest calibration: at a few thousand messages a second, Dart is fine, and the &lt;a href="https://kaguc.com/blog/flutter-highfreq-state/">conflation pattern&lt;/a> means your render cost is already independent of feed rate. Most products that reach for native here have a conflation problem, not a language problem.&lt;/p>
&lt;p>&lt;strong>A counter-threshold applies:&lt;/strong> if nobody on the team can maintain C++ on two platforms, the answer is no regardless of the measurement. An unmaintainable optimization is a liability, and this is the one place where team capability legitimately overrides a number.&lt;/p>
&lt;p>&lt;strong>Ship a local database&lt;/strong> when the catalog exceeds roughly &lt;strong>5,000–10,000 rows or ~1 MB&lt;/strong>, or whenever search must work offline.&lt;/p>
&lt;p>There&amp;rsquo;s a perception argument that usually settles it earlier: interaction feels instantaneous under about &lt;strong>100 ms&lt;/strong> (Nielsen&amp;rsquo;s classic thresholds: 0.1 s instant, 1 s uninterrupted flow, 10 s attention lost). Mobile network p95 round-trip rarely fits in 100 ms. So if search-as-you-type must feel instant, local is not an optimization — it&amp;rsquo;s the only design that meets the requirement.&lt;/p>
&lt;p>&lt;strong>Custom-paint your charts&lt;/strong> when a charting package can&amp;rsquo;t express what you need — synchronized indicator panes, drawing tools, precise crosshair semantics — or above roughly &lt;strong>2,000 rendered elements&lt;/strong>, or when the series updates at frame rate. Below that, use the library.&lt;/p>
&lt;p>&lt;strong>Extract feature packages&lt;/strong> when the team on one codebase reaches about &lt;strong>4–5 developers&lt;/strong>, or when incremental builds exceed &lt;strong>~60 seconds&lt;/strong>, or when the same area produces repeated merge conflicts (say 3+ per month), or when a layer needs testing in isolation.&lt;/p>
&lt;p>These are conventions, not physics. The underlying variable is coordination cost, and it rises with people, not with lines of code. Conway&amp;rsquo;s prediction gives the sanity check: modules should number about what your teams number.&lt;/p>
&lt;p>&lt;strong>Adopt a shared cross-platform core&lt;/strong> when you have &lt;strong>two or more independent client codebases&lt;/strong> &lt;em>and&lt;/em> a body of domain rules where divergence would be a defect. If one team ships both platforms from one Flutter codebase, the sharing already happened; a separate shared core solves a problem you don&amp;rsquo;t have.&lt;/p>
&lt;p>&lt;strong>Add-to-app&lt;/strong> is not a threshold at all — it&amp;rsquo;s a situation. You adopt it when an existing native app must keep shipping while you migrate. If there&amp;rsquo;s nothing to embed into, the question is void.&lt;/p>
&lt;h2 id="try-the-cheap-fixes-first-in-order">Try the cheap fixes first, in order&lt;/h2>
&lt;p>Most threshold violations should never reach an architectural change. Work down this ladder and stop at the first rung that fixes it:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Do less work.&lt;/strong> Smaller payloads, fewer fields, &lt;code>LIMIT&lt;/code> on queries, pagination, fewer visible items. Usually the biggest win and always the cheapest.&lt;/li>
&lt;li>&lt;strong>Do it less often.&lt;/strong> Conflate, debounce, cache, memoize. &lt;a href="https://kaguc.com/blog/flutter-highfreq-state/">Frame coalescing&lt;/a> alone converts many &amp;ldquo;we need native&amp;rdquo; situations into non-problems.&lt;/li>
&lt;li>&lt;strong>Do it in a smaller scope.&lt;/strong> Narrow the rebuild, split the repaint layer, invalidate less.&lt;/li>
&lt;li>&lt;strong>Do it elsewhere.&lt;/strong> Move to an isolate or to the server.&lt;/li>
&lt;li>&lt;strong>Do it in a faster language.&lt;/strong> Native, FFI.&lt;/li>
&lt;/ol>
&lt;p>Rung 5 is where most of this series&amp;rsquo; &amp;ldquo;heavy machinery&amp;rdquo; lives, and reaching it before trying 1 through 4 is the most common way teams end up with a complicated architecture and the same performance.&lt;/p>
&lt;h2 id="when-not-to-adopt-even-past-the-threshold">When &lt;em>not&lt;/em> to adopt, even past the threshold&lt;/h2>
&lt;p>Four counter-signals, each of which should stop you:&lt;/p>
&lt;p>&lt;strong>You can&amp;rsquo;t operate it.&lt;/strong> Certificate pinning without a rehearsed rotation and a kill switch is a self-inflicted outage waiting for a cert to expire. Native code without a maintainer is a bug you can&amp;rsquo;t fix. Capability to run a thing is part of its cost.&lt;/p>
&lt;p>&lt;strong>The trigger was a spike, not a level.&lt;/strong> One bad day at market open is not a threshold crossing. Require the violation to be sustained — over a week of real usage, not one profiling session.&lt;/p>
&lt;p>&lt;strong>A cheaper rung is untried.&lt;/strong> If you haven&amp;rsquo;t done 1 through 4 above, you don&amp;rsquo;t yet know whether you have a problem or a workload.&lt;/p>
&lt;p>&lt;strong>It&amp;rsquo;s a one-way door.&lt;/strong> Reversible decisions deserve fast, cheap deliberation; hard-to-reverse ones deserve more. A binary protocol you can revert in an afternoon is a two-way door. A persisted schema, a public API contract, or a native module three teams now depend on is not — those deserve the slow version of this process.&lt;/p>
&lt;h2 id="the-procedure">The procedure&lt;/h2>
&lt;ol>
&lt;li>Write the target before you need it: metric, percentile, reference device, load.&lt;/li>
&lt;li>Measure under realistic conditions. Not a synthetic benchmark, not a flagship phone.&lt;/li>
&lt;li>If violated, walk the ladder from rung 1.&lt;/li>
&lt;li>If the ladder is exhausted and the violation is sustained, adopt — and check the four counter-signals first.&lt;/li>
&lt;li>Record the decision: what triggered it, what you measured, what you&amp;rsquo;d need to see to reverse it. Six months later this is the difference between an architecture and an accretion.&lt;/li>
&lt;/ol>
&lt;p>Step 5 matters more than it looks. Most bad architecture isn&amp;rsquo;t a bad decision; it&amp;rsquo;s a decision nobody can re-examine because the reasoning was never written down.&lt;/p>
&lt;h2 id="so-is-the-mvp-the-whole-story">So is the MVP the whole story?&lt;/h2>
&lt;p>No — and it shouldn&amp;rsquo;t read as the ceiling. The &lt;a href="https://kaguc.com/blog/flutter-trading-mvp/">reference MVP&lt;/a> is the correct &lt;em>starting point&lt;/em> for its constraints, and the upgrade table there names the seams. This article supplies the numbers that fire each upgrade. Together they describe a path, not a destination.&lt;/p>
&lt;p>Roughly, four tiers, so you can locate yourself:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tier&lt;/th>
&lt;th>Load and team&lt;/th>
&lt;th>What it runs&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&amp;lt;10 updates/s, 1–2 devs&lt;/td>
&lt;td>One codebase, REST + WebSocket, JSON, in-memory cache, chart library&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>~100 updates/s, 2–5 devs&lt;/td>
&lt;td>+ frame conflation, local catalog DB with delta sync, custom chart painter&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>~1,000 updates/s, several teams&lt;/td>
&lt;td>+ decode isolate, binary codec, feature packages, shared domain core if two codebases&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>10,000+ updates/s, many teams, regulated&lt;/td>
&lt;td>+ native ingest, custom transport, module registry, full audit and reconciliation&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two things are worth noticing about that table. Every tier is a superset of the previous one, which is what makes the path walkable — nothing is thrown away, seams are filled in. And Box B from the top of this article applies at &lt;strong>every&lt;/strong> tier, including tier 1. A one-developer app that places real orders needs idempotency keys and server-side authorization exactly as much as a tier-4 system does.&lt;/p>
&lt;p>That&amp;rsquo;s the whole judgment framework: &lt;strong>correctness has no threshold; performance does; and the threshold is a number on a reference device, not an opinion.&lt;/strong>&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;em>API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).&lt;/em>&lt;/p>
&lt;ul>
&lt;li>J. Nielsen, &lt;a href="https://www.nngroup.com/articles/response-times-3-important-limits/">&amp;ldquo;Response Times: The 3 Important Limits&amp;rdquo;&lt;/a>, 1993 — 0.1 s, 1 s, 10 s.&lt;/li>
&lt;li>J. Dean, L. A. Barroso, &lt;a href="https://research.google/pubs/the-tail-at-scale/">&amp;ldquo;The Tail at Scale&amp;rdquo;&lt;/a>, &lt;em>CACM&lt;/em> 56(2), 2013 — why percentiles, not averages, describe what a user experiences.&lt;/li>
&lt;li>J. Bezos, &lt;a href="https://s2.q4cdn.com/299287126/files/doc_financials/annual/2015-Letter-to-Shareholders.PDF">Amazon 2015 Letter to Shareholders&lt;/a>, 2016 — Type 1 (one-way door) vs Type 2 (two-way door) decisions.&lt;/li>
&lt;li>M. E. Conway, &lt;a href="https://www.melconway.com/Home/Committees_Paper.html">&amp;ldquo;How Do Committees Invent?&amp;rdquo;&lt;/a>, &lt;em>Datamation&lt;/em>, 1968 — system structure mirrors the communication structure of the organization that built it.&lt;/li>
&lt;li>D. Goldberg, &lt;a href="https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html">&amp;ldquo;What Every Computer Scientist Should Know About Floating-Point Arithmetic&amp;rdquo;&lt;/a>, &lt;em>ACM Computing Surveys&lt;/em> 23(1), 1991 — why binary floating point cannot represent decimal money.&lt;/li>
&lt;li>Dart docs, &lt;a href="https://dart.dev/language/isolates">Isolates&lt;/a> — messages are copied between isolates; &lt;code>Isolate.run&lt;/code> transfers its result instead. See also &lt;a href="https://api.dart.dev/dart-isolate/TransferableTypedData-class.html">&lt;code>TransferableTypedData&lt;/code>&lt;/a>.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/tools/devtools/performance">Use the Performance view&lt;/a> — UI and raster timelines, frame budget, and the rebuild counter.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Back to &lt;a href="https://kaguc.com/blog/trading-app-architecture/">the overview&lt;/a> · The concrete starting point: &lt;a href="https://kaguc.com/blog/flutter-trading-mvp/">reference MVP&lt;/a>&lt;/em>&lt;/p></description></item><item><title>A reference design: quotes and watchlist MVP in Flutter</title><link>https://kaguc.com/blog/flutter-trading-mvp/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/flutter-trading-mvp/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/flutter-trading-mvp-zh/">中文&lt;/a>. The closing part of &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>.&lt;/em>&lt;/p>
&lt;p>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.&lt;/p>
&lt;p>Committing matters. It&amp;rsquo;s easy to write &amp;ldquo;it depends&amp;rdquo;; the useful artifact is a specific plan, the reasoning that produced it, and an explicit account of what it refuses to build.&lt;/p>
&lt;h2 id="the-constraints-stated-honestly">The constraints, stated honestly&lt;/h2>
&lt;p>Before choosing anything, the six magnitudes from &lt;a href="https://kaguc.com/blog/trading-app-architecture/">the overview&lt;/a>, answered for &lt;em>this&lt;/em> product:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Constraint&lt;/th>
&lt;th>Our magnitude&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Freshness&lt;/td>
&lt;td>1-second quotes, a few hundred visible symbols&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Irreversibility&lt;/td>
&lt;td>none in v1 — read-only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Catalog&lt;/td>
&lt;td>~150k instruments, searchable offline&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Platforms&lt;/td>
&lt;td>iOS + Android, one small team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Teams&lt;/td>
&lt;td>one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Threat&lt;/td>
&lt;td>public market data; user accounts in v2&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Five of six are small. That decides most of the architecture, and it decides it in the direction of &lt;em>less&lt;/em>.&lt;/p>
&lt;h2 id="the-design">The design&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">WebSocket ──▶ decode (Dart) ──▶ FrameCoalescer ──▶ QuoteStore ──▶ leaf widgets
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">REST ──▶ catalog delta sync ──▶ SQLite (bundled snapshot) ──▶ search
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Transport.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>Ingest.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>Conflation.&lt;/strong> The &lt;code>FrameCoalescer&lt;/code> from &lt;a href="https://kaguc.com/blog/flutter-highfreq-state/">the state article&lt;/a> 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.&lt;/p>
&lt;p>&lt;strong>State.&lt;/strong> Per-symbol &lt;code>ValueNotifier&lt;/code>s in a &lt;code>QuoteStore&lt;/code>; leaves subscribe individually. Page-level state can be whatever you like — the granularity discipline only matters on the hot path.&lt;/p>
&lt;p>&lt;strong>Catalog.&lt;/strong> 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 &lt;a href="https://kaguc.com/blog/flutter-offline-store/">the offline article&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Charts.&lt;/strong> &lt;code>CustomPainter&lt;/code> 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.&lt;/p>
&lt;p>&lt;strong>Structure.&lt;/strong> One package with clear folders. Not a monorepo — see below.&lt;/p>
&lt;h2 id="what-we-deliberately-do-not-build">What we deliberately do not build&lt;/h2>
&lt;p>This list is the actual content of the design. Anything can be added; the discipline is in the sequencing.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>No native code, no FFI.&lt;/strong> A 1 Hz feed does not justify a boundary that costs you stack traces and two toolchains.&lt;/li>
&lt;li>&lt;strong>No custom binary protocol.&lt;/strong> JSON over WebSocket until payload size or parse time is measurably a problem.&lt;/li>
&lt;li>&lt;strong>No shared cross-platform core.&lt;/strong> One Flutter codebase already covers both platforms; a shared domain layer solves a problem we don&amp;rsquo;t have.&lt;/li>
&lt;li>&lt;strong>No package-per-feature monorepo.&lt;/strong> One team, one app: &lt;a href="https://kaguc.com/blog/flutter-project-structure/">package boundaries&lt;/a> would be overhead, not enforcement.&lt;/li>
&lt;li>&lt;strong>No add-to-app.&lt;/strong> Nothing to embed into.&lt;/li>
&lt;li>&lt;strong>No certificate pinning.&lt;/strong> Public data, and pinning is an operational commitment we cannot yet honor. Standard TLS, done properly.&lt;/li>
&lt;li>&lt;strong>No order state machine.&lt;/strong> No orders. When they arrive, &lt;a href="https://kaguc.com/blog/order-reliability/">that article&lt;/a> becomes mandatory reading, not optional.&lt;/li>
&lt;/ul>
&lt;p>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 &amp;ldquo;not yet, and here&amp;rsquo;s what would change my mind.&amp;rdquo;&lt;/p>
&lt;h2 id="build-order">Build order&lt;/h2>
&lt;p>&lt;strong>Phase 1 — catalog and search.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>Phase 2 — live quotes.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>Phase 3 — charts.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>Phase 4 — accounts and orders.&lt;/strong> Auth, then the full &lt;a href="https://kaguc.com/blog/order-reliability/">order reliability&lt;/a> design: idempotency keys persisted before send, an unknown state, reconciliation on reconnect. This phase changes the threat model, so &lt;a href="https://kaguc.com/blog/client-security-model/">the security article&lt;/a> applies from its first commit.&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="the-upgrade-path">The upgrade path&lt;/h2>
&lt;p>The design&amp;rsquo;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 &lt;em>measured&lt;/em> trigger:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Element&lt;/th>
&lt;th>Where it plugs in&lt;/th>
&lt;th>Adopt when&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Isolate for decode&lt;/td>
&lt;td>behind the feed interface&lt;/td>
&lt;td>UI-thread profile shows decode in frame budget&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Binary protocol&lt;/td>
&lt;td>codec inside the feed&lt;/td>
&lt;td>payload size or parse time measurably hurts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Native/FFI ingest&lt;/td>
&lt;td>replaces the feed implementation&lt;/td>
&lt;td>conflation in Dart can&amp;rsquo;t keep up — likely never at this rate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Feature packages&lt;/td>
&lt;td>extract from folders&lt;/td>
&lt;td>two people repeatedly collide, or a layer needs isolated tests&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Shared domain core&lt;/td>
&lt;td>extract &lt;code>domain/&lt;/code>&lt;/td>
&lt;td>a second client appears that must not diverge&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Order machinery&lt;/td>
&lt;td>new module&lt;/td>
&lt;td>the first irreversible action ships&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>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.&lt;/p>
&lt;h2 id="the-one-thing-to-keep">The one thing to keep&lt;/h2>
&lt;p>If a reader takes a single idea from this series: &lt;strong>write down your six magnitudes before you choose anything, then adopt each heavy element on the day a measurement demands it.&lt;/strong>&lt;/p>
&lt;p>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.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;em>API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).&lt;/em>&lt;/p>
&lt;ul>
&lt;li>M. Fowler, &lt;a href="https://martinfowler.com/bliki/Yagni.html">&amp;ldquo;Yagni&amp;rdquo;&lt;/a>, 2015 — the cost of a presumptive feature is not only building it but carrying it.&lt;/li>
&lt;li>D. McKinley, &lt;a href="https://boringtechnology.club/">&amp;ldquo;Choose Boring Technology&amp;rdquo;&lt;/a>, 2015 — innovation tokens, and why JSON over a WebSocket is the right first answer.&lt;/li>
&lt;li>J. Bezos, &lt;a href="https://s2.q4cdn.com/299287126/files/doc_financials/annual/2015-Letter-to-Shareholders.PDF">Amazon 2015 Letter to Shareholders&lt;/a>, 2016 — Type 1 (one-way door) vs Type 2 (two-way door) decisions.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/flutter-project-structure/">Flutter project structure&lt;/a> · Back to &lt;a href="https://kaguc.com/blog/trading-app-architecture/">the overview&lt;/a>.&lt;/em>&lt;/p></description></item><item><title>Channels, Pigeon, and FFI: crossing to native without paying per tick</title><link>https://kaguc.com/blog/flutter-native-channels/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/flutter-native-channels/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/flutter-native-channels-zh/">中文&lt;/a>. Flutter implementation notes for &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>; see also &lt;a href="https://kaguc.com/blog/cross-platform-boundary/">the cross-platform boundary&lt;/a>.&lt;/em>&lt;/p>
&lt;p>There are three ways out of Dart, and they are not interchangeable. Choosing well is less about performance benchmarks than about matching the mechanism to how often you cross and what you need on the other side.&lt;/p>
&lt;h2 id="the-three-mechanisms">The three mechanisms&lt;/h2>
&lt;p>&lt;strong>Platform channels&lt;/strong> (&lt;code>MethodChannel&lt;/code>, &lt;code>EventChannel&lt;/code>) send messages through the engine to platform code. They are &lt;strong>asynchronous&lt;/strong>, they &lt;strong>serialize&lt;/strong> arguments through a codec, and they hop threads. That makes them ideal for platform capabilities — permissions, the camera, biometrics, share sheets — where calls are occasional and the work is on the platform side anyway. Cost is dominated by the per-call overhead, not by argument size.&lt;/p>
&lt;p>&lt;strong>Pigeon&lt;/strong> is code generation on top of channels. You declare an interface once, and it generates typed Dart and platform code. The runtime cost is the same as a hand-written channel; what you get is the elimination of an entire class of bug — misspelled method names, mismatched argument shapes, silent type coercion — that channels invite because they are stringly typed and untyped in both directions. If you have more than a couple of channel methods, use it.&lt;/p>
&lt;p>&lt;strong>&lt;code>dart:ffi&lt;/code>&lt;/strong> calls C directly. It is &lt;strong>synchronous&lt;/strong>, has &lt;strong>no serialization&lt;/strong>, and can share memory with Dart via typed data. It is the only option when you need to touch a native library&amp;rsquo;s API surface directly or move large buffers cheaply. The catch is exactly its virtue: a synchronous call runs on the calling isolate, so a slow FFI call blocks whatever isolate invoked it — and if that&amp;rsquo;s the UI isolate, you have dropped frames. As of Flutter 3.47 / Dart 3.13 (August 2026), the supported way to bundle the native side is a build hook (&lt;code>hook/build.dart&lt;/code>) in an FFI package — &lt;code>flutter create --template=package_ffi&lt;/code>, recommended since Flutter 3.38 — which replaces the old per-platform plugin scaffolding for C code.&lt;/p>
&lt;h2 id="the-number-that-decides-it">The number that decides it&lt;/h2>
&lt;p>The cost of crossing is paid &lt;strong>per call&lt;/strong>. This one fact settles most designs.&lt;/p>
&lt;p>A feed delivering 5,000 messages a second, marshalled individually over a channel, is 5,000 serialization round trips and thread hops per second — and it will be slower than doing all the work in Dart, despite native code being &amp;ldquo;faster.&amp;rdquo; You would have paid thousands of transitions to save some decoding.&lt;/p>
&lt;p>The same feed, decoded and conflated natively with Dart reading a snapshot &lt;strong>once per frame&lt;/strong>, is 60 crossings a second carrying a batch. Same data, two orders of magnitude fewer boundary costs.&lt;/p>
&lt;p>So the rule is the one from &lt;a href="https://kaguc.com/blog/realtime-data-pipeline/">the pipeline article&lt;/a>, stated for Flutter: &lt;strong>cross on the frame, never on the tick.&lt;/strong> Design the native interface as a small number of coarse operations that move a batch, not as a stream of individual events or a set of property getters.&lt;/p>
&lt;h2 id="a-concrete-shape-for-a-high-rate-feed">A concrete shape for a high-rate feed&lt;/h2>
&lt;p>If you do end up with native ingest, the interface that works looks like this: native owns the socket, decodes, detects gaps, and maintains a current-value table. Dart asks for what changed since it last looked.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="kd">class&lt;/span> &lt;span class="nc">QuoteBridge&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">QuoteBridge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">_lib&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">NativeLib&lt;/span> &lt;span class="n">_lib&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">/// Copies changed quotes into a reusable buffer. One crossing per frame.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="n">drainInto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Float64List&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">_lib&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">drain&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">out&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">address&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">length&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two properties matter here. The buffer is &lt;strong>reused&lt;/strong> rather than allocated per call, so you are not creating garbage 60 times a second. And the call returns a &lt;em>count&lt;/em> into a preallocated region rather than constructing objects across the boundary, because allocating Dart objects from native code per element reintroduces the per-item cost you were avoiding.&lt;/p>
&lt;p>Then keep the FFI call off the UI isolate if it can ever be slow:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// Long-running or blocking work belongs on its own isolate.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">decoded&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">Isolate&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">run&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">heavyDecode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bytes&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For native code that needs to call &lt;em>into&lt;/em> Dart — a completion callback, a push notification from the feed — use &lt;code>NativeCallable&lt;/code>, which gives you a function pointer that safely re-enters the Dart isolate. Do not try to invoke Dart from an arbitrary native thread without it.&lt;/p>
&lt;h2 id="memory-ownership-must-be-explicit">Memory ownership must be explicit&lt;/h2>
&lt;p>FFI removes the serialization boundary, and with it the automatic memory management you were implicitly relying on. Decide and document, for every buffer crossing the line, &lt;strong>who allocates and who frees&lt;/strong>.&lt;/p>
&lt;p>The two patterns that hold up:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Dart allocates, native fills.&lt;/strong> Dart owns a typed-data buffer for its whole lifetime and passes it down. Simplest, and my default — there is no ambiguity about lifetime.&lt;/li>
&lt;li>&lt;strong>Native allocates, Dart frees explicitly.&lt;/strong> Wrap it so release is not optional: &lt;code>using((arena) { ... })&lt;/code> for scoped allocations, or attach a &lt;code>NativeFinalizer&lt;/code> so a dropped Dart object releases its native peer.&lt;/li>
&lt;/ul>
&lt;p>The failure here is not a crash you catch in testing. It&amp;rsquo;s a slow leak that shows up as a memory graph climbing over an hour of use, which is precisely the session length you never test.&lt;/p>
&lt;h2 id="eventchannel-has-no-backpressure">EventChannel has no backpressure&lt;/h2>
&lt;p>Worth stating on its own, because it surprises people: an &lt;code>EventChannel&lt;/code> fed faster than Dart consumes will queue. There is no built-in mechanism for the consumer to say &amp;ldquo;slow down,&amp;rdquo; so a fast native producer turns into unbounded buffering and then a memory problem. (The symptom is on record as far back as flutter/flutter#33640: a native producer feeding an &lt;code>EventChannel&lt;/code> continuously, and memory climbing at a steady rate.)&lt;/p>
&lt;p>If native is the producer of a high-rate stream, &lt;strong>conflate on the native side&lt;/strong> and let Dart pull, or push at a rate you control (once per frame) rather than at the rate data arrives. Applying backpressure is the producer&amp;rsquo;s job here because the transport won&amp;rsquo;t do it for you.&lt;/p>
&lt;h2 id="choosing-briefly">Choosing, briefly&lt;/h2>
&lt;ul>
&lt;li>Occasional platform capability → &lt;strong>Pigeon-generated channel&lt;/strong>. Type safety is free; the call rate is irrelevant.&lt;/li>
&lt;li>A handful of calls, no codegen appetite → a plain &lt;code>MethodChannel&lt;/code> is fine. Keep the method surface small.&lt;/li>
&lt;li>Large buffers, or a native library you must talk to directly → &lt;strong>FFI&lt;/strong>, with explicit ownership and a helper isolate for anything slow.&lt;/li>
&lt;li>High-rate stream → native conflates, Dart drains &lt;strong>once per frame&lt;/strong> over FFI. Never per-message channels.&lt;/li>
&lt;li>Anything you can do in Dart at acceptable cost → &lt;strong>do it in Dart.&lt;/strong> The boundary is a permanent tax on debugging, crash reporting, and build complexity, and it should be justified by a measurement rather than an intuition about speed.&lt;/li>
&lt;/ul>
&lt;p>That last one is the most common mistake. A stack trace stops at the boundary, your crash reporter needs separate symbol uploads for each side, and a reproduction now involves two toolchains. Cross when the profile says to, and not because native feels faster.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;em>API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).&lt;/em>&lt;/p>
&lt;ul>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/platform-integration/platform-channels">Writing custom platform-specific code&lt;/a> — &lt;code>MethodChannel&lt;/code> / &lt;code>EventChannel&lt;/code>, codecs, and threading rules.&lt;/li>
&lt;li>&lt;a href="https://pub.dev/packages/pigeon">&lt;code>pigeon&lt;/code>&lt;/a> on pub.dev — typed code generation over platform channels.&lt;/li>
&lt;li>Dart docs, &lt;a href="https://dart.dev/interop/c-interop">C interop using dart:ffi&lt;/a> — &lt;code>@Native&lt;/code>, &lt;code>NativeCallable&lt;/code>, &lt;code>NativeFinalizer&lt;/code>, and &lt;code>Arena&lt;/code> / &lt;code>using&lt;/code>.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/platform-integration/bind-native-code">Bind to native code using FFI&lt;/a> and Dart docs, &lt;a href="https://dart.dev/tools/hooks">Hooks&lt;/a> — &lt;code>hook/build.dart&lt;/code> and the &lt;code>package_ffi&lt;/code> template.&lt;/li>
&lt;li>Dart docs, &lt;a href="https://dart.dev/language/isolates">Isolates&lt;/a> — messages are copied between isolates; &lt;code>Isolate.run&lt;/code> transfers its result instead. See also &lt;a href="https://api.dart.dev/dart-isolate/TransferableTypedData-class.html">&lt;code>TransferableTypedData&lt;/code>&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/flutter/flutter/issues/33640">flutter/flutter#33640&lt;/a> — steady memory growth from a native producer feeding an &lt;code>EventChannel&lt;/code> faster than Dart consumes.&lt;/li>
&lt;li>J. Waldo, G. Wyant, A. Wollrath, S. Kendall, &lt;a href="https://scholar.harvard.edu/files/waldo/files/waldo-94.pdf">&amp;ldquo;A Note on Distributed Computing&amp;rdquo;&lt;/a>, Sun Microsystems Laboratories TR-94-29, 1994 — a boundary between address spaces cannot be papered over; latency and partial failure are paid per call.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/flutter-highfreq-state/">Flutter under a firehose&lt;/a> · Next: &lt;a href="https://kaguc.com/blog/flutter-chart-rendering/">Charts and custom painting&lt;/a>&lt;/em>&lt;/p></description></item><item><title>Charts in Flutter: custom painting that holds 60fps</title><link>https://kaguc.com/blog/flutter-chart-rendering/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/flutter-chart-rendering/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/flutter-chart-rendering-zh/">中文&lt;/a>. Flutter implementation notes for &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>.&lt;/em>&lt;/p>
&lt;p>Most Flutter performance advice is about the UI thread: fewer rebuilds, smaller subtrees. A chart moves the problem to the &lt;strong>raster thread&lt;/strong>, where the question is not how many widgets you built but how much work you asked the GPU to do, and how often.&lt;/p>
&lt;h2 id="dont-compose-a-chart-out-of-widgets">Don&amp;rsquo;t compose a chart out of widgets&lt;/h2>
&lt;p>The instinct is to make each candle a widget. Six hundred candles then become six hundred widgets to build, lay out, and paint, plus their parents — and every pan gesture redoes all of it.&lt;/p>
&lt;p>A chart is one drawing, not many objects. Use a single &lt;code>CustomPainter&lt;/code> and draw into a canvas. You give up the framework&amp;rsquo;s layout and hit-testing for that region, and in exchange you get a cost proportional to &lt;em>pixels&lt;/em> rather than to &lt;em>elements&lt;/em>, plus complete control over what happens per frame.&lt;/p>
&lt;p>The widget tree remains the right tool for the toolbar, the legend, and the crosshair readout. It is the wrong tool for the series itself.&lt;/p>
&lt;h2 id="shouldrepaint-is-the-whole-game">&lt;code>shouldRepaint&lt;/code> is the whole game&lt;/h2>
&lt;p>This is the method that decides whether your chart is cheap or ruinous, and returning &lt;code>true&lt;/code> — the safe-looking answer — means repainting on every frame forever.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">CandlePainter&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">CustomPainter&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">CandlePainter&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="kd">required&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">series&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">required&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">viewport&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">required&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">revision&lt;/span>&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">CandleSeries&lt;/span> &lt;span class="n">series&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">Viewport&lt;/span> &lt;span class="n">viewport&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// pan/zoom
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="n">revision&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// bumped when series data changes
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">bool&lt;/span> &lt;span class="n">shouldRepaint&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">CandlePainter&lt;/span> &lt;span class="n">old&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">old&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">revision&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">revision&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="n">old&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">viewport&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">viewport&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Compare a cheap &lt;strong>version counter&lt;/strong> rather than the data itself. Deep-comparing a few thousand candles on every frame is its own performance problem, and identity comparison silently fails the moment you mutate a list in place. A monotonic revision that you bump on every data change is unambiguous and free.&lt;/p>
&lt;h2 id="split-static-from-live">Split static from live&lt;/h2>
&lt;p>Within one chart, different parts change at wildly different rates. Grid lines, axis labels, and completed candles change when you pan or when a bar closes. The last price line, the crosshair, and the in-progress candle change constantly.&lt;/p>
&lt;p>Painting them together means the live element forces a repaint of everything.&lt;/p>
&lt;p>So stack two painters, each in its own &lt;code>RepaintBoundary&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="n">Stack&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">children:&lt;/span> &lt;span class="kd">const&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">RepaintBoundary&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">child:&lt;/span> &lt;span class="n">CustomPaint&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">painter:&lt;/span> &lt;span class="n">StaticChartPainter&lt;/span>&lt;span class="p">(...))),&lt;/span> &lt;span class="c1">// grid, closed candles
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">RepaintBoundary&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">child:&lt;/span> &lt;span class="n">CustomPaint&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">painter:&lt;/span> &lt;span class="n">LivePainter&lt;/span>&lt;span class="p">(...))),&lt;/span> &lt;span class="c1">// last price, crosshair
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now a tick repaints a layer containing a couple of lines. The expensive layer — thousands of candles and text labels — is cached as a texture and composited, not redrawn.&lt;/p>
&lt;p>This is the single highest-leverage change in most chart implementations, and it follows the same principle as &lt;a href="https://kaguc.com/blog/flutter-highfreq-state/">scoping rebuilds on the UI thread&lt;/a>: make the invalidated region match the thing that changed.&lt;/p>
&lt;h2 id="dont-allocate-inside-paint">Don&amp;rsquo;t allocate inside &lt;code>paint()&lt;/code>&lt;/h2>
&lt;p>&lt;code>paint()&lt;/code> may run 60 times a second. Everything created there becomes garbage at that rate, and GC pauses are precisely the tail-latency problem you are trying to avoid.&lt;/p>
&lt;p>Hoist &lt;code>Paint&lt;/code> objects to fields. Reuse &lt;code>Path&lt;/code> objects with &lt;code>reset()&lt;/code> instead of constructing new ones. Precompute your data-to-pixel transform once per paint rather than per candle. And for large point counts, prefer the batch primitives — &lt;code>drawRawPoints&lt;/code>, &lt;code>drawVertices&lt;/code>, or a single &lt;code>Path&lt;/code> — over thousands of individual &lt;code>drawLine&lt;/code> calls, since each canvas call carries overhead that dominates when the shape is tiny.&lt;/p>
&lt;h2 id="text-is-the-hidden-cost">Text is the hidden cost&lt;/h2>
&lt;p>Axis labels and price scales look free and are not: laying out text is one of the more expensive operations in a paint pass, and doing it for forty labels every frame will show up in your raster timings.&lt;/p>
&lt;p>Cache &lt;code>TextPainter&lt;/code> instances keyed by the string, and rebuild them only when the label set changes. Since price and time labels repeat heavily across frames, a small map gets you most of the way.&lt;/p>
&lt;h2 id="interaction-transform-the-canvas-not-the-data">Interaction: transform the canvas, not the data&lt;/h2>
&lt;p>Pan and zoom should change a viewport (an offset and a scale) that you apply to the canvas, not rewrite your data:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="n">canvas&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">save&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">canvas&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">translate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">viewport&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">offsetX&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="m">0&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">canvas&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">scale&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">viewport&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">scaleX&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="m">1&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// draw in data space
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">canvas&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">restore&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Recomputing pixel positions for every candle on every gesture frame is the common cause of chart panning that feels heavy. Keep the model in data coordinates and let the canvas transform do the work.&lt;/p>
&lt;p>Hit testing runs the other way: convert the pointer position into data coordinates and binary-search the series for the nearest bar. That is a log-n lookup per gesture event instead of a scan.&lt;/p>
&lt;h2 id="shader-compilation-briefly">Shader compilation, briefly&lt;/h2>
&lt;p>Historically, the first frame that used a new shader could compile it on the spot and drop that frame — the classic &amp;ldquo;janks once, then never again&amp;rdquo; bug that only reproduces on a fresh install. Impeller — as of Flutter 3.47 (August 2026) the only renderer on iOS and the default on Android API 29+ — compiles shaders ahead of time and removes this class of stutter. Older Android devices still fall back to the OpenGL/Skia path, so if you support them, test a fresh install there.&lt;/p>
&lt;p>The practical guidance is short: stay on Impeller, and keep effects conventional. Exotic blend modes, large blurs, and heavy &lt;code>Opacity&lt;/code> over complex subtrees are still expensive on any backend, because they force offscreen render passes. &lt;code>Opacity&lt;/code> around a big subtree is the one most often reached for by accident — prefer opacity baked into a &lt;code>Paint&lt;/code> color when you are painting anyway.&lt;/p>
&lt;h2 id="measure-the-raster-thread">Measure the raster thread&lt;/h2>
&lt;p>Open the DevTools performance view and watch the two timelines separately. If UI frames are fine and raster frames are long, the fix is in this article: fewer layers, smaller invalidated regions, cheaper paint operations. If UI frames are long, your problem is upstream in the widget tree and belongs to &lt;a href="https://kaguc.com/blog/flutter-highfreq-state/">the state article&lt;/a>.&lt;/p>
&lt;p>Test with a realistic series — a few thousand bars — on a mid-range device. A chart that is smooth with 50 candles on a flagship tells you nothing about the case that matters.&lt;/p>
&lt;h2 id="when-to-just-use-a-library">When to just use a library&lt;/h2>
&lt;p>If you need a standard line or bar chart that updates a few times a minute, a charting package is the right answer and everything above is premature.&lt;/p>
&lt;p>Write your own when you need behavior the library does not have: synchronized indicator panes, custom drawing tools, precise crosshair semantics, tens of thousands of points, or ticking updates at frame rate. Those requirements are what justify owning a painter — not the belief that custom code will be faster in general.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;em>API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).&lt;/em>&lt;/p>
&lt;ul>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/perf/impeller">Impeller rendering engine&lt;/a> — the only renderer on iOS; default on Android API 29+ with an OpenGL fallback below; default on desktop since 3.47.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/perf/shader">Shader compilation jank&lt;/a> — the first-run stutter, and Impeller as the fix.&lt;/li>
&lt;li>Flutter API, &lt;a href="https://api.flutter.dev/flutter/rendering/CustomPainter-class.html">&lt;code>CustomPainter&lt;/code>&lt;/a> and &lt;a href="https://api.flutter.dev/flutter/widgets/RepaintBoundary-class.html">&lt;code>RepaintBoundary&lt;/code>&lt;/a> — &lt;code>shouldRepaint&lt;/code> semantics and layer isolation.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/perf/best-practices">Performance best practices&lt;/a> — &lt;code>const&lt;/code> widgets, &lt;code>RepaintBoundary&lt;/code>, &lt;code>itemExtent&lt;/code>, and why &lt;code>Opacity&lt;/code> and &lt;code>saveLayer&lt;/code> are expensive.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/tools/devtools/performance">Use the Performance view&lt;/a> — UI and raster timelines, frame budget, and the rebuild counter.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/flutter-native-channels/">Channels, Pigeon, and FFI&lt;/a> · Next: &lt;a href="https://kaguc.com/blog/flutter-offline-store/">Offline storage and local search&lt;/a>&lt;/em>&lt;/p></description></item><item><title>Flutter project structure: packages, boundaries, and add-to-app</title><link>https://kaguc.com/blog/flutter-project-structure/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/flutter-project-structure/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/flutter-project-structure-zh/">中文&lt;/a>. Flutter implementation notes for &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>; the principle is in &lt;a href="https://kaguc.com/blog/modular-evolution/">Modularity and migration&lt;/a>.&lt;/em>&lt;/p>
&lt;p>Folder conventions do not create boundaries. A &lt;code>lib/features/&lt;/code> directory is a suggestion, and any file can import any other file across it. Once more than a few people work in a codebase, suggestions stop holding.&lt;/p>
&lt;p>Dart packages are the smallest unit where a boundary is actually enforced — a package cannot import what it doesn&amp;rsquo;t declare. That is the whole reason to reach for them.&lt;/p>
&lt;h2 id="a-package-per-feature">A package per feature&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">apps/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> trading_app/ # composition root: wiring, routes, flavors
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">packages/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> design_system/ # tokens, components, theme
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> domain/ # entities, value objects, rules — no Flutter import
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> data/ # repositories, api client, database
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> feature_quotes/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> feature_watchlist/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> feature_orders/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>One rule makes this work: &lt;strong>features depend on &lt;code>domain&lt;/code>, &lt;code>data&lt;/code>, and &lt;code>design_system&lt;/code>; features never depend on each other; only the app depends on features.&lt;/strong>&lt;/p>
&lt;p>That single constraint is what buys you anything. It means a feature can be developed, tested, and deleted in isolation; that a change to &lt;code>feature_orders&lt;/code> cannot break &lt;code>feature_quotes&lt;/code> by construction; and that your build system can compile and test them in parallel.&lt;/p>
&lt;p>The corollary that people resist: if two features need the same thing, it belongs in &lt;code>domain&lt;/code> or a shared package — not imported sideways. Sideways imports are how a modular codebase quietly becomes a monolith with extra &lt;code>pubspec.yaml&lt;/code> files.&lt;/p>
&lt;p>Keep &lt;code>domain&lt;/code> free of Flutter entirely. It should be plain Dart: entities, rules, and the interfaces the outside world implements. That makes it fast to test without a widget binding, and it means the business logic could outlive a UI framework change — the practical form of &lt;a href="https://kaguc.com/blog/modular-evolution/">dependency inversion&lt;/a>.&lt;/p>
&lt;h2 id="enforce-the-rule-with-a-tool">Enforce the rule with a tool&lt;/h2>
&lt;p>A boundary nobody checks is a boundary that erodes in the first busy sprint. Make violations fail CI.&lt;/p>
&lt;p>The cheap version is a lint that forbids importing across features, using &lt;code>custom_lint&lt;/code> or an analysis option banning specific import paths. The cheaper version still: because features are separate packages, an undeclared import is already a compile error — you get most of the enforcement for free simply by &lt;em>not&lt;/em> putting everything in one package.&lt;/p>
&lt;p>Add a check that no package depends on the app, and that &lt;code>domain&lt;/code> depends on nothing but Dart. Both are one-line rules that catch real architectural drift.&lt;/p>
&lt;h2 id="routing-as-a-registry">Routing as a registry&lt;/h2>
&lt;p>If the app imports every feature&amp;rsquo;s screens directly, adding a feature means editing the app, and the app becomes the coupling point you tried to avoid.&lt;/p>
&lt;p>Instead have each feature expose its routes, and let the app compose them:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// in feature_quotes
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">quotesRoutes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">RouteDef&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">RouteDef&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/quotes/:symbol&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">QuoteDetailPage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">symbol:&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;symbol&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// in apps/trading_app
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">router&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AppRouter&lt;/span>&lt;span class="p">([&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">...&lt;/span>&lt;span class="n">quotesRoutes&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">...&lt;/span>&lt;span class="n">watchlistRoutes&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">...&lt;/span>&lt;span class="n">ordersRoutes&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Navigation between features then goes through paths rather than through imported widget classes, which is what keeps features from depending on each other while still being able to link to one another. It also gives deep links a single place to be validated — and per &lt;a href="https://kaguc.com/blog/client-security-model/">the security article&lt;/a>, an incoming link must be treated as hostile input and re-checked at the destination, not trusted because it arrived.&lt;/p>
&lt;h2 id="composition-at-the-root-only">Composition at the root only&lt;/h2>
&lt;p>Dependency injection belongs in the app package. Features declare what they need in constructors; the root builds the object graph.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// apps/trading_app/lib/bootstrap.dart
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">db&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">openCatalog&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">api&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ApiClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">baseUrl:&lt;/span> &lt;span class="n">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">apiUrl&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">quotes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">QuoteRepository&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">api:&lt;/span> &lt;span class="n">api&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">db:&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">runApp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">App&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">quotes:&lt;/span> &lt;span class="n">quotes&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Reaching for a global service locator from inside a feature undoes this: the feature now has a hidden dependency that doesn&amp;rsquo;t appear in its constructor, cannot be substituted in a test without global setup, and makes the package non-portable. A locator at the composition root is fine. A locator called from a widget deep in a feature is a global variable with good manners.&lt;/p>
&lt;h2 id="add-to-app-and-what-it-costs">Add-to-app, and what it costs&lt;/h2>
&lt;p>If you are embedding Flutter into an existing native app — the &lt;a href="https://kaguc.com/blog/modular-evolution/">strangler-fig migration&lt;/a> — there are four costs to plan for.&lt;/p>
&lt;p>&lt;strong>Engine startup.&lt;/strong> The first Flutter screen pays engine initialization. Pre-warm a cached engine during app start so the first navigation isn&amp;rsquo;t the moment the user waits.&lt;/p>
&lt;p>&lt;strong>Multiple engines.&lt;/strong> &lt;code>FlutterEngineGroup&lt;/code> lets additional engines share the snapshot and most of the memory cost, which makes several live engines viable where independent engines would not be. It is still not free — prefer one engine with internal routing unless you genuinely need isolated screens.&lt;/p>
&lt;p>&lt;strong>Two navigation stacks.&lt;/strong> The native stack and Flutter&amp;rsquo;s router both exist, and the back button must behave sensibly across the seam. Decide early who owns the stack for a given flow, and route all back handling through that decision rather than patching cases as they surface.&lt;/p>
&lt;p>&lt;strong>Duplicated state.&lt;/strong> Session, theme, and locale now live on both sides. Make one side authoritative and push changes across a single channel; do not let both sides maintain their own copy and hope they agree.&lt;/p>
&lt;p>Pass initial route and arguments when creating the engine, and return results through one narrow channel rather than several ad-hoc ones — the coarse-boundary rule from &lt;a href="https://kaguc.com/blog/flutter-native-channels/">the channels article&lt;/a> applies to navigation too.&lt;/p>
&lt;h2 id="when-this-is-too-much">When this is too much&lt;/h2>
&lt;p>For one to three developers on a single app, this structure is overhead: more &lt;code>pubspec.yaml&lt;/code> files, more version wrangling (pub workspaces, Dart 3.6+, remove most of that — one resolution and one lockfile for the repo — but not the rest), slower cold builds, and indirection with nothing on the other side of it.&lt;/p>
&lt;p>Start with one package and folders. Extract a package when a boundary starts to hurt — you want to test a layer without a widget binding, two people keep colliding in the same area, or you need to reuse something in a second app. Those are observations. &amp;ldquo;We might grow&amp;rdquo; is not, and a small team maintaining a large team&amp;rsquo;s structure pays coordination costs for an organization it doesn&amp;rsquo;t have.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;em>API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).&lt;/em>&lt;/p>
&lt;ul>
&lt;li>Dart docs, &lt;a href="https://dart.dev/tools/pub/workspaces">Pub workspaces&lt;/a> (Dart 3.6+) — one resolution and one lockfile across the packages of a monorepo.&lt;/li>
&lt;li>&lt;a href="https://pub.dev/packages/custom_lint">&lt;code>custom_lint&lt;/code>&lt;/a> on pub.dev — project-specific lints, including import-boundary rules, that fail CI.&lt;/li>
&lt;li>R. C. Martin, &lt;a href="https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html">&amp;ldquo;The Clean Architecture&amp;rdquo;&lt;/a>, 2012 — the dependency rule: source dependencies point inward, toward policy.&lt;/li>
&lt;li>D. L. Parnas, &lt;a href="https://doi.org/10.1145/361598.361623">&amp;ldquo;On the Criteria To Be Used in Decomposing Systems into Modules&amp;rdquo;&lt;/a>, &lt;em>CACM&lt;/em> 15(12), 1972 — a module should hide a decision that is likely to change.&lt;/li>
&lt;li>M. Fowler, &lt;a href="https://martinfowler.com/articles/injection.html">&amp;ldquo;Inversion of Control Containers and the Dependency Injection pattern&amp;rdquo;&lt;/a>, 2004 — dependency injection vs service locator, and what each costs in testability.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/add-to-app">Add Flutter to an existing app&lt;/a> and &lt;a href="https://docs.flutter.dev/add-to-app/multiple-flutters">Multiple Flutter instances&lt;/a> — engine warm-up, &lt;code>FlutterEngineGroup&lt;/code>, and the cost of several live engines.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/flutter-offline-store/">Offline storage and search&lt;/a> · Next: &lt;a href="https://kaguc.com/blog/flutter-trading-mvp/">A reference design for a quotes MVP&lt;/a>&lt;/em>&lt;/p></description></item><item><title>Flutter under a firehose: making rebuild scope match change scope</title><link>https://kaguc.com/blog/flutter-highfreq-state/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/flutter-highfreq-state/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/flutter-highfreq-state-zh/">中文&lt;/a>. Flutter implementation notes for &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>; the principle behind it is in &lt;a href="https://kaguc.com/blog/realtime-data-pipeline/">Real-time data pipelines&lt;/a>.&lt;/em>&lt;/p>
&lt;p>Put 300 symbols on screen, each updating a few times a second, and the standard Flutter approach falls over. Not because Flutter is slow — because the standard approach rebuilds a subtree proportional to the &lt;em>screen&lt;/em> on every change proportional to a &lt;em>single cell&lt;/em>.&lt;/p>
&lt;p>The fix is one idea applied ruthlessly: &lt;strong>the rebuilt subtree should be as small as the thing that changed.&lt;/strong> Everything below is a consequence.&lt;/p>
&lt;h2 id="why-the-ordinary-way-fails">Why the ordinary way fails&lt;/h2>
&lt;p>The usual shape is a store holding a map of quotes, exposed to the tree, with the list watching it. One tick arrives, the store notifies, and the list rebuilds — 300 rows, each with a handful of widgets, so a thousand-plus &lt;code>build()&lt;/code> calls for one number that moved.&lt;/p>
&lt;p>At 60 fps you have 16.6 ms per frame for everything: build, layout, paint, raster. A few hundred ticks a second against a screen-sized rebuild will not fit, and the symptom is the worst kind — it&amp;rsquo;s fine in development with mock data and janky exactly when the market is interesting.&lt;/p>
&lt;p>Note that this is not a criticism of any particular state management package. Provider, Bloc, Riverpod, and &lt;code>setState&lt;/code> all behave correctly here; the problem is that the &lt;em>granularity&lt;/em> was chosen wrong. You can build this badly with any of them, and well with most of them.&lt;/p>
&lt;h2 id="rebuild-only-the-leaf">Rebuild only the leaf&lt;/h2>
&lt;p>Give each symbol its own listenable, and let the widget that displays it be the only thing subscribed.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">QuoteStore&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">_notifiers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ValueNotifier&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Quote&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span>&lt;span class="p">{};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ValueListenable&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Quote&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">listen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">String&lt;/span> &lt;span class="n">symbol&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_notifiers&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">putIfAbsent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">symbol&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">ValueNotifier&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Quote&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">empty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">symbol&lt;/span>&lt;span class="p">)));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// called from the feed, many times per second
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kt">void&lt;/span> &lt;span class="n">apply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Quote&lt;/span> &lt;span class="n">q&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_notifiers&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">q&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">symbol&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">n&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// nobody is showing it — do no work
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">value&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">q&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// no visible change — do no work
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">q&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// notifies exactly one subtree
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">PriceCell&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">StatelessWidget&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">const&lt;/span> &lt;span class="n">PriceCell&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">symbol&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="k">super&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">QuoteStore&lt;/span> &lt;span class="n">store&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="kt">String&lt;/span> &lt;span class="n">symbol&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Widget&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BuildContext&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">ValueListenableBuilder&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Quote&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nl">valueListenable:&lt;/span> &lt;span class="n">store&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">listen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">symbol&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">q&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">__&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">Text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">q&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">last&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toStringAsFixed&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The row, the list, and the page never rebuild. A tick for &lt;code>AAPL&lt;/code> rebuilds one &lt;code>Text&lt;/code>. The cost of an update is now proportional to what changed rather than to what is on screen — which is the property you were after.&lt;/p>
&lt;p>Two details in &lt;code>apply()&lt;/code> do a lot of work. Skipping symbols nobody is displaying turns an off-screen subscription leak into a no-op, and the equality check absorbs the very common case of a message that carries no visible change.&lt;/p>
&lt;h2 id="conflate-onto-the-frame-not-onto-the-tick">Conflate onto the frame, not onto the tick&lt;/h2>
&lt;p>Rebuilding one leaf per tick is cheap, but 20 ticks per second per symbol is still 19 wasted rebuilds — the screen only shows 60 frames, and a human perceives far fewer.&lt;/p>
&lt;p>So buffer incoming ticks and flush once per frame:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">FrameCoalescer&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">FrameCoalescer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">_flush&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="kt">void&lt;/span> &lt;span class="n">Function&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Iterable&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Quote&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="n">_flush&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">_pending&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Quote&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">{};&lt;/span> &lt;span class="c1">// keyed: last one wins
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kt">bool&lt;/span> &lt;span class="n">_scheduled&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kt">void&lt;/span> &lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Quote&lt;/span> &lt;span class="n">q&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_pending&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">q&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">symbol&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">q&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// conflation, for free
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">_scheduled&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_scheduled&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">SchedulerBinding&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">instance&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">scheduleFrameCallback&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_scheduled&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">batch&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_pending&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">values&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toList&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">growable:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_pending&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">clear&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_flush&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">batch&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The map does the conflation: ten ticks for one symbol between two frames collapse to one, automatically, because a later write replaces an earlier one under the same key. This is the &lt;em>register&lt;/em> case from &lt;a href="https://kaguc.com/blog/realtime-data-pipeline/">the pipeline article&lt;/a> expressed in nine lines.&lt;/p>
&lt;p>Now your update rate is bounded by the frame rate no matter what the market does. A volatile open costs slightly more decoding and exactly the same rendering.&lt;/p>
&lt;h2 id="keep-the-list-itself-cheap">Keep the list itself cheap&lt;/h2>
&lt;p>Even with perfect leaf updates, the list can undo your work:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>ListView.builder&lt;/code> with &lt;code>itemExtent&lt;/code>.&lt;/strong> A known extent lets Flutter skip measuring children to compute scroll geometry — a large win for uniform rows.&lt;/li>
&lt;li>&lt;strong>&lt;code>const&lt;/code> constructors&lt;/strong> everywhere they&amp;rsquo;re possible. A &lt;code>const&lt;/code> widget is skipped during rebuild instead of being reconstructed and compared.&lt;/li>
&lt;li>&lt;strong>&lt;code>RepaintBoundary&lt;/code> around independently-animating cells&lt;/strong>, so a flashing price cell doesn&amp;rsquo;t dirty the whole list&amp;rsquo;s layer. Use it deliberately: each boundary is a layer, and hundreds of them cost memory.&lt;/li>
&lt;li>&lt;strong>Hoist the notifier lookup.&lt;/strong> Calling &lt;code>listen(symbol)&lt;/code> inside &lt;code>build()&lt;/code> is fine when it&amp;rsquo;s a cheap map read, but never create the notifier during build if creating it has side effects like subscribing upstream.&lt;/li>
&lt;/ul>
&lt;h2 id="subscribe-from-the-widget-lifecycle">Subscribe from the widget lifecycle&lt;/h2>
&lt;p>Screen visibility should drive subscriptions, or you will pay for data nobody is looking at:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="n">initState&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">super&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">initState&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_sub&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">feed&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">subscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">widget&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">symbols&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// reference-counted upstream
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="n">dispose&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_sub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">cancel&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// last listener → unsubscribe
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">super&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">dispose&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Reference-count in the feed so two widgets showing the same symbol produce one upstream subscription, and the subscription ends when the second one goes. Combine that with app lifecycle: on &lt;code>AppLifecycleState.paused&lt;/code>, drop to a slow summary or stop entirely.&lt;/p>
&lt;h2 id="measure-the-right-thread">Measure the right thread&lt;/h2>
&lt;p>Flutter has two threads that matter, and they fail differently. The &lt;strong>UI thread&lt;/strong> runs your Dart: &lt;code>build&lt;/code>, layout, and the framework. The &lt;strong>raster thread&lt;/strong> turns the result into pixels.&lt;/p>
&lt;p>Everything above targets the UI thread. If DevTools shows the raster thread as the bottleneck, rebuild scope is not your problem — you have expensive painting, too many layers, or shader compilation, which is &lt;a href="https://kaguc.com/blog/flutter-chart-rendering/">the chart article&amp;rsquo;s&lt;/a> territory.&lt;/p>
&lt;p>Use the DevTools rebuild counter to confirm what you assume: put the app under a realistic feed, watch which widgets rebuild, and check the count matches the number of things that actually changed. When those two numbers agree, this problem is solved.&lt;/p>
&lt;h2 id="when-to-skip-all-of-it">When to skip all of it&lt;/h2>
&lt;p>If your data changes every few seconds, a single stream feeding one &lt;code>StreamBuilder&lt;/code> at the top of the page is correct and this article is overhead. Per-symbol notifiers, frame coalescing, and repaint boundaries are what you reach for when a measurement says a screen-sized rebuild no longer fits in a frame — not before.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;em>API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).&lt;/em>&lt;/p>
&lt;ul>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/perf/best-practices">Performance best practices&lt;/a> — &lt;code>const&lt;/code> widgets, &lt;code>RepaintBoundary&lt;/code>, &lt;code>itemExtent&lt;/code>, and why &lt;code>Opacity&lt;/code> and &lt;code>saveLayer&lt;/code> are expensive.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/resources/architectural-overview">Flutter architectural overview&lt;/a> — the UI and raster threads, and what runs on each.&lt;/li>
&lt;li>Flutter API, &lt;a href="https://api.flutter.dev/flutter/widgets/ValueListenableBuilder-class.html">&lt;code>ValueListenableBuilder&lt;/code>&lt;/a> and &lt;a href="https://api.flutter.dev/flutter/scheduler/SchedulerBinding/scheduleFrameCallback.html">&lt;code>SchedulerBinding.scheduleFrameCallback&lt;/code>&lt;/a> — the two primitives the leaf-rebuild and frame-coalescing patterns are built on.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/tools/devtools/performance">Use the Performance view&lt;/a> — UI and raster timelines, frame budget, and the rebuild counter.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Next in the Flutter notes: &lt;a href="https://kaguc.com/blog/flutter-native-channels/">Channels, Pigeon, and FFI&lt;/a> — how to cross to native without paying per tick.&lt;/em>&lt;/p></description></item><item><title>Local-first data: snapshots, deltas, and the two kinds of state</title><link>https://kaguc.com/blog/local-first-sync/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/local-first-sync/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/local-first-sync-zh/">中文&lt;/a>. Part 3 of &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>.&lt;/em>&lt;/p>
&lt;p>An app that deals with a large domain holds two kinds of data, and they have nearly opposite properties:&lt;/p>
&lt;p>&lt;strong>Reference data&lt;/strong> is the catalog of things that exist — instruments, exchanges, currencies, precision rules. It is large, changes slowly, and is mostly appended to. A user expects to search it instantly, including with no network.&lt;/p>
&lt;p>&lt;strong>Live data&lt;/strong> is the current value of things — prices, positions, balances. Each item is tiny, changes constantly, and is worthless when stale.&lt;/p>
&lt;p>Most difficulty in &amp;ldquo;sync&amp;rdquo; comes from pushing both through one mechanism. Split them and each becomes tractable: live data is a stream (the &lt;a href="https://kaguc.com/blog/realtime-data-pipeline/">previous part&lt;/a>), and reference data is a replication problem with an unusually easy shape.&lt;/p>
&lt;h2 id="why-the-catalog-belongs-on-the-device">Why the catalog belongs on the device&lt;/h2>
&lt;p>The requirement &amp;ldquo;search must be instant, always&amp;rdquo; cannot be met by a network call. Not because a network call is slow on average, but because its &lt;em>worst case&lt;/em> is unbounded — and search is judged on the worst case, in an elevator, on a train.&lt;/p>
&lt;p>So the device holds the catalog and the network becomes an enhancement rather than a dependency. This is what local-first means in practice: &lt;strong>the local copy is good enough to work from, and the network&amp;rsquo;s job is to make it fresher, not to make it exist.&lt;/strong>&lt;/p>
&lt;p>That inverts the usual failure story. A conventional client is broken while offline. A local-first client is merely &lt;em>stale&lt;/em> while offline, and staleness in a slow-changing catalog is often unobservable. The difference in perceived quality is large and it costs you very little, because the underlying data was never fast-moving to begin with.&lt;/p>
&lt;h2 id="snapshot-plus-deltas-keyed-by-a-monotonic-version">Snapshot plus deltas, keyed by a monotonic version&lt;/h2>
&lt;p>The mechanism is small:&lt;/p>
&lt;p>The server keeps a &lt;strong>monotonically increasing version&lt;/strong> for the catalog. Every change bumps it. The client stores the version it has reached and asks a single question on launch: &lt;em>what has changed since version n?&lt;/em> The server returns the deltas; the client applies them and records the new high-water mark.&lt;/p>
&lt;p>That question is the entire protocol, and it has properties worth naming:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Resumable.&lt;/strong> An interrupted sync loses nothing. You resume from the last version you fully applied — so apply the batch and update the marker in one transaction, or you will eventually skip a delta.&lt;/li>
&lt;li>&lt;strong>Idempotent.&lt;/strong> Re-applying a range you already have is harmless if deltas are expressed as upserts and tombstones rather than in-place mutations.&lt;/li>
&lt;li>&lt;strong>Cheap to verify.&lt;/strong> Two integers tell you whether a client is current.&lt;/li>
&lt;/ul>
&lt;p>This is why the catalog is the &lt;em>easy&lt;/em> side of cache invalidation. Invalidation is hard when you must guess whether a cached thing is still valid. Here you never guess: the version answers it exactly, and the data is append-mostly, so the answer is usually &amp;ldquo;add these few rows.&amp;rdquo;&lt;/p>
&lt;p>Two versions must not be conflated: the &lt;strong>data version&lt;/strong> (how fresh the contents are) and the &lt;strong>schema version&lt;/strong> (what shape the local store is in). App updates change the schema; server changes bump the data. Store and migrate them separately, or one upgrade will make you discard a database you only needed to add a column to.&lt;/p>
&lt;h2 id="shipping-the-snapshot-inside-the-app">Shipping the snapshot inside the app&lt;/h2>
&lt;p>You can start every user at version zero and download the catalog on first launch — a few hundred thousand rows over a mobile network, before they can search anything.&lt;/p>
&lt;p>Or you ship a snapshot inside the binary and start them at the version it was built at. First launch, first search, works offline, no spinner.&lt;/p>
&lt;p>The trade is install size against first-run experience, and for a catalog that is genuinely required for the product to function, the snapshot usually wins. It is a deliberate exchange of bytes users download &lt;em>once&lt;/em>, in the background, from an app store built for it, against a fragile network moment at the least forgiving point in the product.&lt;/p>
&lt;p>The catalog then only ever gets deltas. A user who installs a year later gets a bigger delta, which is the one cost of this design and the reason you re-cut the shipped snapshot periodically.&lt;/p>
&lt;h2 id="what-must-never-be-local-authoritative">What must never be local-authoritative&lt;/h2>
&lt;p>The device can own the catalog. It must not own the ledger.&lt;/p>
&lt;p>Balances, positions, and order status are the server&amp;rsquo;s truth. Cache them for display, but treat the cache as a &lt;em>rendering of a remote fact&lt;/em>, with two rules: reconcile on reconnect and cold start, and never let a local value participate in a decision that has consequences. Local state answers &amp;ldquo;what do I show,&amp;rdquo; never &amp;ldquo;what is true.&amp;rdquo;&lt;/p>
&lt;p>The line is drawn by a single question: &lt;strong>if this value is wrong, is the outcome a stale screen or a wrong action?&lt;/strong> Stale screen is a caching problem. Wrong action is a correctness problem, and correctness problems live on the server.&lt;/p>
&lt;h2 id="keep-the-local-surface-read-mostly">Keep the local surface read-mostly&lt;/h2>
&lt;p>Everything above is easy because the replication is &lt;strong>one-way&lt;/strong>. The server writes, the client reads. There are no conflicts, because there is only one writer.&lt;/p>
&lt;p>The moment the client also writes to the same data, you inherit the genuinely hard part of distributed systems: two edits to one record, no global clock, and a need to converge. Now you need conflict resolution — last-write-wins with a version vector, CRDTs, or a server-side merge — and you need to accept that some resolution will be wrong in a way users notice.&lt;/p>
&lt;p>There is usually a way to keep local writes off this path: model user-generated state (a watchlist, a note) as &lt;em>its own&lt;/em> small collection with its own sync, rather than as edits to the replicated catalog. One-way replication for the big thing, deliberate two-way sync for the small thing you actually need.&lt;/p>
&lt;h2 id="scaling-this-down">Scaling this down&lt;/h2>
&lt;p>The whole design is forced by three properties: the catalog is &lt;strong>large&lt;/strong>, &lt;strong>slow-changing&lt;/strong>, and &lt;strong>needed offline&lt;/strong>. Remove any one of them and something simpler is correct:&lt;/p>
&lt;ul>
&lt;li>Catalog small enough to fetch in one call? Fetch it and cache in memory.&lt;/li>
&lt;li>Changes constantly? It isn&amp;rsquo;t reference data — treat it as a stream.&lt;/li>
&lt;li>Never needed offline? A search endpoint, with the server doing the ranking it is better at anyway.&lt;/li>
&lt;/ul>
&lt;p>The failure mode worth avoiding is building version-tracked delta sync for a few hundred rows that would have been a single request. Snapshot-plus-delta is what you graduate to when the catalog gets big enough that re-downloading it is the thing users complain about.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>M. Kleppmann, A. Wiggins, P. van Hardenberg, M. McGranaghan, &lt;a href="https://www.inkandswitch.com/local-first/">&amp;ldquo;Local-first software: You own your data, in spite of the cloud&amp;rdquo;&lt;/a>, Ink &amp;amp; Switch / Onward! 2019 — the source of the term and the seven ideals behind it.&lt;/li>
&lt;li>M. Kleppmann, &lt;a href="https://dataintensive.net/">&lt;em>Designing Data-Intensive Applications&lt;/em>&lt;/a>, O&amp;rsquo;Reilly, 2017 — ch. 5 (replication) and ch. 11 (stream processing) are the background for the catalog and feed designs.&lt;/li>
&lt;li>J. Kreps, &lt;a href="https://web.archive.org/web/20190913020020/https://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying">&amp;ldquo;The Log: What every software engineer should know about real-time data&amp;rsquo;s unifying abstraction&amp;rdquo;&lt;/a>, LinkedIn Engineering, 2013 (archived) — the log as an append-only sequence where every entry matters, and snapshot + offset as the replication primitive.&lt;/li>
&lt;li>M. Shapiro, N. Preguiça, C. Baquero, M. Zawirski, &lt;a href="https://inria.hal.science/inria-00609399">&amp;ldquo;Conflict-free Replicated Data Types&amp;rdquo;&lt;/a>, &lt;em>SSS&lt;/em> 2011 — what you inherit the moment two writers edit one record.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/order-reliability/">Order reliability&lt;/a> · Next: &lt;a href="https://kaguc.com/blog/cross-platform-boundary/">The cross-platform boundary&lt;/a>&lt;/em>&lt;/p></description></item><item><title>Modularity and migration: cutting boundaries that survive</title><link>https://kaguc.com/blog/modular-evolution/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/modular-evolution/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/modular-evolution-zh/">中文&lt;/a>. Part 5 of &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>.&lt;/em>&lt;/p>
&lt;p>A long-lived app has a property no design document mentions: it must keep shipping while it changes shape. Nobody gets to stop for a year. That constraint, more than any technical one, determines which architectures survive.&lt;/p>
&lt;p>Two old ideas do most of the work here, and a third tells you how to move.&lt;/p>
&lt;h2 id="your-architecture-will-look-like-your-org">Your architecture will look like your org&lt;/h2>
&lt;p>Conway&amp;rsquo;s observation is that a system&amp;rsquo;s module boundaries end up mirroring the communication boundaries of the organization that produced it. It is not a warning; it&amp;rsquo;s a prediction that holds well enough to design against.&lt;/p>
&lt;p>The practical reading: if eight teams work on one app, you will get roughly eight modules. The only question is whether you chose them. Boundaries that match how people actually work become cheap — a team ships without coordinating. Boundaries that cut across teams become expensive forever, because every change requires a meeting.&lt;/p>
&lt;p>So modularity is not only a code-structuring exercise. It is a decision about &lt;strong>who can ship without asking whom&lt;/strong>, and that is why it shows up in build times, review latency, and how often a release is blocked by an unrelated team.&lt;/p>
&lt;h2 id="cut-where-decisions-are-likely-to-change">Cut where decisions are likely to change&lt;/h2>
&lt;p>The criterion is Parnas&amp;rsquo;s, from 1972, and it is still the most useful sentence about modularity: a module should &lt;strong>hide a decision that is likely to change.&lt;/strong>&lt;/p>
&lt;p>Note what it does &lt;em>not&lt;/em> say. It doesn&amp;rsquo;t say group by technical layer — all models here, all views there. That arrangement is the one most teams reach for and it inverts the goal: a typical change is a feature, and a feature touches a model, a view, and a service, so every change touches every module. You have created boundaries that add ceremony and prevent nothing.&lt;/p>
&lt;p>The test is simple: &lt;strong>take your last ten meaningful changes and see how many modules each touched.&lt;/strong> If most touched one, your boundaries match reality. If most touched four, your modules are shaped like your file types instead of your changes, and the fix is to cut along features and volatile decisions instead — the market data protocol, the chart engine, the auth flow, the persistence choice.&lt;/p>
&lt;h2 id="the-core-should-be-handed-its-dependencies">The core should be handed its dependencies&lt;/h2>
&lt;p>Once you have modules, the question is which way the arrows point. Dependency inversion answers it: the core declares the interfaces it needs — a network transport, a store, a clock, a logger — and the outside world supplies implementations. The core depends on nothing concrete.&lt;/p>
&lt;p>Three things follow, and they are the reason this pattern keeps reappearing in large systems:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The core is testable in isolation&lt;/strong>, because every collaborator can be replaced with a fake. Including the clock, which is how you test a timeout without waiting for one.&lt;/li>
&lt;li>&lt;strong>Infrastructure can be replaced&lt;/strong> without touching business rules. The HTTP client is an implementation detail if and only if nothing in the core names it.&lt;/li>
&lt;li>&lt;strong>The interface list documents the real dependencies.&lt;/strong> A core initialized with a dozen injected capabilities tells you exactly what it needs from the world — often a more honest architecture diagram than the one in the wiki.&lt;/li>
&lt;/ul>
&lt;p>The corresponding smell is a core that reaches out: constructs its own HTTP client, reads the system clock, calls a singleton logger. It works, and it quietly welds the business rules to the infrastructure. You discover this the day you try to test it.&lt;/p>
&lt;h2 id="registries-buy-decoupling-and-charge-for-safety">Registries buy decoupling and charge for safety&lt;/h2>
&lt;p>When modules must find each other without compile-time dependencies, teams reach for a registry: modules register implementations at startup, consumers look them up by interface.&lt;/p>
&lt;p>The benefit is real — modules build and ship independently, and nothing needs a compile-time reference to a sibling.&lt;/p>
&lt;p>The cost is equally real: a missing binding is no longer a compile error, it&amp;rsquo;s a crash at runtime, in a code path someone reaches on a Tuesday. You have traded a guarantee for flexibility.&lt;/p>
&lt;p>If you take that trade, pay down the risk deliberately: keep interfaces in small contract modules both sides depend on, and &lt;strong>validate the full registry at startup in debug builds&lt;/strong> so a missing binding fails immediately and loudly rather than deep in a user flow. Flexible wiring is fine; silent flexible wiring is not.&lt;/p>
&lt;h2 id="migrate-by-growing-the-new-inside-the-old">Migrate by growing the new inside the old&lt;/h2>
&lt;p>Eventually a large app needs a technology it didn&amp;rsquo;t start with. The instinct is a rewrite, and the reason rewrites fail is structural, not motivational: you are aiming at a moving target. The existing product keeps gaining features while you rebuild, so you must reach parity with something that is still growing, and until you do, you ship nothing.&lt;/p>
&lt;p>The strangler-fig approach inverts it. Grow the new system inside the old one, one screen at a time, until the old one can be removed. Concretely that needs four things:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>A seam&lt;/strong> — usually routing or navigation — where a destination can be served by either implementation.&lt;/li>
&lt;li>&lt;strong>Parity per screen&lt;/strong>, not per app. One screen at a time is a shippable unit; a whole app is not.&lt;/li>
&lt;li>&lt;strong>Both runtimes coexisting&lt;/strong>, with the costs from &lt;a href="https://kaguc.com/blog/cross-platform-boundary/">the previous part&lt;/a> accepted explicitly.&lt;/li>
&lt;li>&lt;strong>A kill switch.&lt;/strong> If the new implementation misbehaves in production, routing falls back. Without this, incremental migration has the same risk profile as a rewrite, just spread out.&lt;/li>
&lt;/ol>
&lt;p>The cost is a period — often years — of maintaining two ways of doing things. That is the price of never having a flag day, and for a product where downtime or a stalled roadmap is unacceptable, it is a good trade. It is also why large apps look &amp;ldquo;inconsistent&amp;rdquo; from the outside: you are seeing a migration mid-flight, which is what a healthy long-lived system looks like.&lt;/p>
&lt;h2 id="dont-buy-this-early">Don&amp;rsquo;t buy this early&lt;/h2>
&lt;p>Everything above is priced for coordination problems. If you don&amp;rsquo;t have them, it&amp;rsquo;s overhead.&lt;/p>
&lt;p>For one to three developers, module boundaries mostly add ceremony: more build configuration, more indirection, more places to look. A registry is strictly worse than calling the thing. The honest default is one codebase with clear internal structure and no enforced boundaries.&lt;/p>
&lt;p>Cut a module when a boundary starts to &lt;strong>hurt&lt;/strong> — repeated merge conflicts in one area, build times you notice, ownership nobody can state, a dependency you want to swap and can&amp;rsquo;t. Those are measurements. &amp;ldquo;We might grow&amp;rdquo; is not.&lt;/p>
&lt;p>The expensive failure isn&amp;rsquo;t a missing module. It&amp;rsquo;s a small team maintaining a large team&amp;rsquo;s architecture, paying the coordination cost of an organization that doesn&amp;rsquo;t exist.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>M. E. Conway, &lt;a href="https://www.melconway.com/Home/Committees_Paper.html">&amp;ldquo;How Do Committees Invent?&amp;rdquo;&lt;/a>, &lt;em>Datamation&lt;/em>, 1968 — system structure mirrors the communication structure of the organization that built it.&lt;/li>
&lt;li>D. L. Parnas, &lt;a href="https://doi.org/10.1145/361598.361623">&amp;ldquo;On the Criteria To Be Used in Decomposing Systems into Modules&amp;rdquo;&lt;/a>, &lt;em>CACM&lt;/em> 15(12), 1972 — a module should hide a decision that is likely to change.&lt;/li>
&lt;li>R. C. Martin, &lt;a href="https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html">&amp;ldquo;The Clean Architecture&amp;rdquo;&lt;/a>, 2012 — the dependency rule: source dependencies point inward, toward policy.&lt;/li>
&lt;li>A. Cockburn, &lt;a href="https://alistair.cockburn.us/hexagonal-architecture/">&amp;ldquo;Hexagonal Architecture&amp;rdquo;&lt;/a>, 2005 — ports and adapters: the core declares what it needs, the outside supplies it.&lt;/li>
&lt;li>M. Fowler, &lt;a href="https://martinfowler.com/bliki/StranglerFigApplication.html">&amp;ldquo;Strangler Fig Application&amp;rdquo;&lt;/a>, 2004 — grow the new system around the edges of the old until the old can be removed.&lt;/li>
&lt;li>M. Fowler, &lt;a href="https://martinfowler.com/articles/injection.html">&amp;ldquo;Inversion of Control Containers and the Dependency Injection pattern&amp;rdquo;&lt;/a>, 2004 — dependency injection vs service locator, and what each costs in testability.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/cross-platform-boundary/">The cross-platform boundary&lt;/a> · Next: &lt;a href="https://kaguc.com/blog/client-security-model/">The client security model&lt;/a>&lt;/em>&lt;/p></description></item><item><title>Order reliability: idempotency, unknown states, and reconciliation</title><link>https://kaguc.com/blog/order-reliability/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/order-reliability/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/order-reliability-zh/">中文&lt;/a>. Part 2 of &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>.&lt;/em>&lt;/p>
&lt;p>You tap submit. The request goes out. Nothing comes back.&lt;/p>
&lt;p>That is the entire problem, and it has no clean solution — only a choice of which failure you are willing to own. A client that ignores this ships a bug that only appears when the network is bad, which is to say, in exactly the conditions where the money matters most.&lt;/p>
&lt;h2 id="the-impossibility-you-must-design-around">The impossibility you must design around&lt;/h2>
&lt;p>When a request times out, two situations are indistinguishable from the client: the server never received it, or the server executed it and the acknowledgement was lost. There is no message you can send, and no timeout you can tune, that separates those cases. The information does not exist on your side.&lt;/p>
&lt;p>So you choose:&lt;/p>
&lt;p>&lt;strong>At-most-once.&lt;/strong> Never retry. You never place a duplicate order, and you sometimes silently fail to place one the user asked for. For money, unacceptable — a dropped order is invisible, and the user finds out from the market.&lt;/p>
&lt;p>&lt;strong>At-least-once.&lt;/strong> Always retry until acknowledged. You never lose an order, and you sometimes place it twice. Also unacceptable on its own, but this one is &lt;em>fixable&lt;/em>.&lt;/p>
&lt;p>You take at-least-once and remove the duplicate with an &lt;strong>idempotency key&lt;/strong>: an identifier the client mints for the &lt;em>intent&lt;/em>, which the server uses to recognize a repeat. First arrival executes. Every subsequent arrival with that key returns the original result without executing again. The retry becomes safe by construction, and &amp;ldquo;exactly once&amp;rdquo; is achieved not at the transport layer, where it is impossible, but at the &lt;strong>semantic&lt;/strong> layer, where it is merely careful bookkeeping.&lt;/p>
&lt;h2 id="mint-the-key-before-you-send-and-persist-it">Mint the key before you send, and persist it&lt;/h2>
&lt;p>This is the detail that separates a design that works from one that looks identical and doesn&amp;rsquo;t.&lt;/p>
&lt;p>The key must be generated and &lt;strong>written to durable storage before the first network call&lt;/strong>, as part of creating the order intent. Not inside the request builder. Not as a retry-time afterthought.&lt;/p>
&lt;p>The reason is the failure you are protecting against. If the app is killed mid-flight, then on relaunch you must be able to ask &amp;ldquo;did that order go through?&amp;rdquo; — and you can only ask if the identity of the attempt survived the crash. A key generated in memory at send time dies with the process, and your recovery path invents a &lt;em>new&lt;/em> key, which the server correctly treats as a &lt;em>new order&lt;/em>. You have implemented duplicate submission with extra steps.&lt;/p>
&lt;p>So the sequence is: create the intent, persist it with its key and a pending status, then send. Every retry, in this process or a later one, reuses that key.&lt;/p>
&lt;h2 id="timeout-is-a-state-not-a-failure">Timeout is a state, not a failure&lt;/h2>
&lt;p>The most common modeling error is a boolean: submitted or not. Reality has a third value.&lt;/p>
&lt;p>Model the order as a state machine with an explicit &lt;strong>unknown&lt;/strong> state, entered on timeout or ambiguous error. Unknown means: an action may or may not have happened, and the client is not entitled to guess.&lt;/p>
&lt;p>What you do in unknown is the important part: &lt;strong>you do not blindly retry, and you do not report failure.&lt;/strong> You &lt;em>ask&lt;/em>. Query order status by the idempotency key, and let the authoritative answer collapse the ambiguity. Only if the server confirms it never saw the request do you resend.&lt;/p>
&lt;p>Blind retry is safe only if your idempotency guarantee is airtight end to end, including across app restarts and server deploys. Query-then-decide is safe regardless. On an irreversible action, prefer the design whose safety doesn&amp;rsquo;t depend on a guarantee elsewhere being perfect.&lt;/p>
&lt;p>Telling the user &amp;ldquo;failed&amp;rdquo; on a timeout is the same class of error in the opposite direction: they resubmit by hand, and now the duplicate is caused by your UI rather than your network layer. An honest pending state is not a worse user experience. It is the only truthful one.&lt;/p>
&lt;h2 id="the-server-is-the-only-source-of-truth">The server is the only source of truth&lt;/h2>
&lt;p>A local &amp;ldquo;sent&amp;rdquo; is not &amp;ldquo;accepted.&amp;rdquo; &amp;ldquo;Accepted&amp;rdquo; is not &amp;ldquo;filled.&amp;rdquo; Only the endpoint that owns the money can make those statements, and this is the end-to-end principle applied to a domain where it has teeth.&lt;/p>
&lt;p>Two consequences for the client:&lt;/p>
&lt;p>&lt;strong>Optimistic UI must be labeled as optimistic.&lt;/strong> Showing a pending order immediately is good; showing it as &lt;em>placed&lt;/em> is a lie you will occasionally have to retract in front of someone who acted on it.&lt;/p>
&lt;p>&lt;strong>Reconcile on every reconnect and every cold start.&lt;/strong> Pull the authoritative order list and let it overwrite local state. Do not merge cleverly. The client&amp;rsquo;s copy is a cache, and a cache that argues with the origin is just a bug with persistence.&lt;/p>
&lt;h2 id="ordering-version-dont-trust-arrival">Ordering: version, don&amp;rsquo;t trust arrival&lt;/h2>
&lt;p>Status updates for one order can arrive out of order — retries, multiple channels, reconnects. If you apply whatever arrives last, a stale &amp;ldquo;submitted&amp;rdquo; can overwrite a fresh &amp;ldquo;filled,&amp;rdquo; and your UI now shows a completed order as in-flight.&lt;/p>
&lt;p>Give every order a monotonically increasing version or sequence assigned &lt;strong>by the server&lt;/strong>, and drop any update whose version is not newer than what you hold. Last-write-wins by version, never by arrival time. Client clocks are not a substitute; they are wrong, and they are wrong in both directions.&lt;/p>
&lt;p>Make illegal transitions loud, too. If you receive &lt;code>filled → submitting&lt;/code>, that is not a state to render, it is a defect to log. Silently accepting impossible transitions is how a data bug becomes a customer&amp;rsquo;s screenshot.&lt;/p>
&lt;h2 id="store-events-not-just-current-state">Store events, not just current state&lt;/h2>
&lt;p>Keep an append-only record of what happened: intent created, submitted, acknowledged, filled. Derive the current state from it.&lt;/p>
&lt;p>You want this for three reasons, and only one of them is regulatory. It gives support and engineering a truthful answer to &amp;ldquo;what did the app actually do at 09:31:04.&amp;rdquo; It makes reconciliation a comparison rather than a guess. And it turns your postmortems from archaeology into reading.&lt;/p>
&lt;p>Mutating a single status field in place destroys exactly the information you will need on the one day you need it badly.&lt;/p>
&lt;h2 id="scaling-this-down">Scaling this down&lt;/h2>
&lt;p>All of the above is the price of &lt;strong>irreversibility&lt;/strong>. Take that away and it collapses.&lt;/p>
&lt;p>If the action is reversible — a saved draft, a like, a preference — a plain request with a retry is correct, and an idempotency key plus a state machine plus an event log plus reconciliation is machinery in search of a problem.&lt;/p>
&lt;p>The trigger for adopting this design is not &amp;ldquo;we handle data,&amp;rdquo; it&amp;rsquo;s &amp;ldquo;&lt;strong>an action that cannot be undone by the user.&lt;/strong>&amp;rdquo; Payments, orders, transfers, irreversible account changes. When you have one, build all of it. When you don&amp;rsquo;t, don&amp;rsquo;t.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>E. A. Akkoyunlu, K. Ekanadham, R. V. Huber, &lt;a href="https://doi.org/10.1145/800213.806523">&amp;ldquo;Some constraints and tradeoffs in the design of network communications&amp;rdquo;&lt;/a>, &lt;em>SOSP&lt;/em> 1975 — the Two Generals problem: agreement over an unreliable channel is impossible, so exactly-once delivery is too.&lt;/li>
&lt;li>P. Helland, &lt;a href="https://queue.acm.org/detail.cfm?id=2187821">&amp;ldquo;Idempotence Is Not a Medical Condition&amp;rdquo;&lt;/a>, &lt;em>ACM Queue&lt;/em> 10(4), 2012 — at-least-once plus idempotent handling, and why a timeout must be modeled as an unknown state.&lt;/li>
&lt;li>J. H. Saltzer, D. P. Reed, D. D. Clark, &lt;a href="https://web.mit.edu/Saltzer/www/publications/endtoend/endtoend.pdf">&amp;ldquo;End-to-End Arguments in System Design&amp;rdquo;&lt;/a>, &lt;em>ACM TOCS&lt;/em> 2(4), 1984 — a guarantee is only real at the endpoint that can verify it.&lt;/li>
&lt;li>L. Lamport, &lt;a href="https://lamport.azurewebsites.net/pubs/time-clocks.pdf">&amp;ldquo;Time, Clocks, and the Ordering of Events in a Distributed System&amp;rdquo;&lt;/a>, &lt;em>CACM&lt;/em> 21(7), 1978 — order by logical version, never by wall clock or arrival.&lt;/li>
&lt;li>Stripe, &lt;a href="https://docs.stripe.com/api/idempotent_requests">Idempotent requests&lt;/a>, and IETF HTTPAPI WG, &lt;a href="https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/">&amp;ldquo;The Idempotency-Key HTTP Header Field&amp;rdquo;&lt;/a> — the production shape of client-minted idempotency keys.&lt;/li>
&lt;li>M. Fowler, &lt;a href="https://martinfowler.com/eaaDev/EventSourcing.html">&amp;ldquo;Event Sourcing&amp;rdquo;&lt;/a>, 2005 — derive current state from an append-only record of what happened.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/realtime-data-pipeline/">Real-time data pipelines&lt;/a> · Next: &lt;a href="https://kaguc.com/blog/local-first-sync/">Local-first data and delta sync&lt;/a>&lt;/em>&lt;/p></description></item><item><title>Real-time data pipelines: push, backpressure, and conflation</title><link>https://kaguc.com/blog/realtime-data-pipeline/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/realtime-data-pipeline/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/realtime-data-pipeline-zh/">中文&lt;/a>. Part 1 of &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>.&lt;/em>&lt;/p>
&lt;p>Most client code assumes a shape: you ask, the server answers, you render. A market data feed breaks that assumption in three places at once. Nobody asks — the data arrives on its own. There is no &amp;ldquo;answer&amp;rdquo; — the stream never ends. And the producer is faster than the consumer, permanently, by design.&lt;/p>
&lt;p>Get those three straight and the architecture of a real-time client mostly writes itself.&lt;/p>
&lt;h2 id="why-the-data-must-be-pushed">Why the data must be pushed&lt;/h2>
&lt;p>The naive approach is to poll: ask every &lt;em>n&lt;/em> milliseconds. It fails on arithmetic before it fails in production.&lt;/p>
&lt;p>Polling puts a floor under your staleness equal to the interval. Want data no more than 100 ms old? Poll ten times a second. Now multiply: every symbol on screen, every open client. The server does full request-handling work on every tick regardless of whether anything changed — and most of the time nothing has. You are paying for the &lt;em>clock&lt;/em>, not for the &lt;em>information&lt;/em>.&lt;/p>
&lt;p>Push inverts the cost. Work happens when the data changes. An idle market costs an idle connection; a volatile one costs exactly the messages that carry news. That is the property you want: &lt;strong>cost proportional to change, not to time.&lt;/strong>&lt;/p>
&lt;p>The price of push is that a connection is no longer an event, it is &lt;em>state you own&lt;/em>. That state needs:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Heartbeats&lt;/strong>, because TCP will happily hold a connection that is functionally dead. Without an application-level ping you cannot distinguish &amp;ldquo;quiet market&amp;rdquo; from &amp;ldquo;we were severed twenty minutes ago.&amp;rdquo; Silence must be made falsifiable.&lt;/li>
&lt;li>&lt;strong>Reconnect with exponential backoff and jitter.&lt;/strong> Backoff so you don&amp;rsquo;t hammer a struggling server; jitter because without it every client that dropped at the same moment returns at the same moment, and your recovery becomes a second outage.&lt;/li>
&lt;li>&lt;strong>A resume token&lt;/strong>, so reconnecting doesn&amp;rsquo;t mean starting from nothing.&lt;/li>
&lt;/ul>
&lt;h2 id="gaps-and-the-difference-between-a-log-and-a-register">Gaps, and the difference between a log and a register&lt;/h2>
&lt;p>Every message carries a sequence number. Not for bookkeeping — for detecting the messages you &lt;em>didn&amp;rsquo;t&lt;/em> get. A silently dropped update in a price feed isn&amp;rsquo;t missing data, it&amp;rsquo;s &lt;strong>wrong data&lt;/strong>: your client is now displaying a number it believes is current and isn&amp;rsquo;t.&lt;/p>
&lt;p>What you do about a gap depends on what kind of stream it is, and this is the classification that drives everything downstream:&lt;/p>
&lt;p>A &lt;strong>register&lt;/strong> holds a current value. The last write wins; earlier writes are irrelevant once superseded. A last-traded price is a register — if you missed three ticks and the fourth arrives, you have lost nothing that the fourth doesn&amp;rsquo;t already tell you.&lt;/p>
&lt;p>A &lt;strong>log&lt;/strong> is a sequence of deltas where each one is meaningful. An order book maintained by incremental updates is a log — miss one and every subsequent update is applied to a corrupt base. The damage is permanent and silent; it compounds.&lt;/p>
&lt;p>So: for a register, a gap is a hiccup. For a log, a gap is a &lt;strong>resync&lt;/strong> — discard local state, request a fresh snapshot, apply increments from that snapshot&amp;rsquo;s sequence forward. The universal pattern is &lt;strong>snapshot plus increments, with the snapshot&amp;rsquo;s sequence number as the join point.&lt;/strong>&lt;/p>
&lt;p>Write down, for each stream in your product, whether it is a log or a register. It is a five-minute exercise that decides the next section for you.&lt;/p>
&lt;h2 id="backpressure-is-the-real-design-problem">Backpressure is the real design problem&lt;/h2>
&lt;p>Your consumer is a phone. Your producer is a market. Under load the producer wins, and &amp;ldquo;wins&amp;rdquo; means your queue grows without bound until the process dies.&lt;/p>
&lt;p>There are only three honest responses:&lt;/p>
&lt;p>&lt;strong>Buffer everything.&lt;/strong> Correct in principle, fatal in practice — memory grows without limit, and worse, the data you eventually render is old. You built a queue that converts a freshness problem into a latency problem and then into a crash.&lt;/p>
&lt;p>&lt;strong>Drop.&lt;/strong> Shed messages when behind. Legitimate for a register, catastrophic for a log.&lt;/p>
&lt;p>&lt;strong>Conflate.&lt;/strong> Keep only the newest value per key, coalescing anything that arrives before you can consume it. Ten ticks for a symbol between two frames collapse into one. This is the right answer for registers, and it is not a degradation: rendering the latest price is &lt;em>exactly correct&lt;/em>, because the intermediate ticks were already superseded before a human could perceive them.&lt;/p>
&lt;p>Conflation is the single highest-leverage idea in a real-time client, and it follows directly from the register/log split. You cannot conflate a log; you must never buffer a register.&lt;/p>
&lt;h2 id="decouple-tick-rate-from-frame-rate">Decouple tick rate from frame rate&lt;/h2>
&lt;p>A screen refreshes 60 times a second. A liquid symbol can update far more often than that. So updates-per-second above 60 have &lt;strong>no representable meaning on the client&lt;/strong> — they can only cost you.&lt;/p>
&lt;p>The architecture that follows: an ingest path that decodes and conflates into a per-symbol current-value map, and a render path that samples that map once per frame. Ticks and frames are now independent. A tenfold burst in market activity changes the ingest cost slightly and the render cost not at all.&lt;/p>
&lt;p>The common failure is wiring the socket directly to UI state, so every message triggers a re-render. That design works beautifully in testing and collapses on the first volatile open, because you accidentally coupled your frame rate to the market&amp;rsquo;s.&lt;/p>
&lt;h2 id="where-the-native-boundary-belongs">Where the native boundary belongs&lt;/h2>
&lt;p>If you cross a language boundary (Dart to C++, JVM to native), the cost is mostly &lt;strong>per call&lt;/strong>, not per byte. That single fact places the boundary for you.&lt;/p>
&lt;p>Put decode, gap detection, conflation, and fan-out on the fast side. Cross once per frame with a batch of changed symbols. Do not cross per message.&lt;/p>
&lt;p>A client that calls across the boundary for every tick can be slower than one that does everything in the managed language, because it pays thousands of transition costs to save a decode. The reason to go native here is not that &amp;ldquo;C++ is fast&amp;rdquo; — it&amp;rsquo;s that a managed runtime&amp;rsquo;s &lt;em>tail&lt;/em> latency is unpredictable. A garbage collection pause is invisible in your average and clearly visible as a stutter at the worst moment. You go native for &lt;strong>timing determinism&lt;/strong> on the hot path, and only for that.&lt;/p>
&lt;h2 id="subscriptions-are-a-lifecycle-not-a-call">Subscriptions are a lifecycle, not a call&lt;/h2>
&lt;p>The last piece is bookkeeping that is easy to underestimate:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Reference-count subscriptions per symbol.&lt;/strong> Two widgets showing the same symbol should produce one subscription, and it should end when the second one leaves.&lt;/li>
&lt;li>&lt;strong>Resubscribe on reconnect.&lt;/strong> Server-side subscription state died with the socket. If your client assumes otherwise, it shows a frozen screen that looks exactly like a quiet market — the worst possible failure, because it is indistinguishable from working.&lt;/li>
&lt;li>&lt;strong>Degrade in the background.&lt;/strong> An app that isn&amp;rsquo;t visible should unsubscribe or drop to a slow summary. This is the difference between a well-behaved app and one users uninstall for battery reasons.&lt;/li>
&lt;/ul>
&lt;h2 id="when-you-need-none-of-this">When you need none of this&lt;/h2>
&lt;p>Everything above is forced by one number: how often your data changes relative to how fast a human perceives it. If a value updates every few seconds, a WebSocket handing JSON straight to your UI state is the &lt;em>correct&lt;/em> design, and every mechanism above is waste.&lt;/p>
&lt;p>The discipline is to add each piece when a measurement demands it, in roughly this order: push before polling hurts; sequencing when correctness demands it; conflation when the queue grows; a native boundary only when profiling shows the decode loop, and never before.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>J. Kreps, &lt;a href="https://web.archive.org/web/20190913020020/https://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying">&amp;ldquo;The Log: What every software engineer should know about real-time data&amp;rsquo;s unifying abstraction&amp;rdquo;&lt;/a>, LinkedIn Engineering, 2013 (archived) — the log as an append-only sequence where every entry matters, and snapshot + offset as the replication primitive.&lt;/li>
&lt;li>&lt;a href="https://www.reactive-streams.org/">Reactive Streams specification&lt;/a>, 2015 — backpressure as a first-class contract between producer and consumer; the three honest responses (buffer, drop, conflate) are what a consumer can do when it has none.&lt;/li>
&lt;li>M. Brooker, &lt;a href="https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/">&amp;ldquo;Exponential Backoff and Jitter&amp;rdquo;&lt;/a>, AWS Architecture Blog, 2015 — why backoff without jitter turns recovery into a second outage.&lt;/li>
&lt;li>J. Dean, L. A. Barroso, &lt;a href="https://research.google/pubs/the-tail-at-scale/">&amp;ldquo;The Tail at Scale&amp;rdquo;&lt;/a>, &lt;em>CACM&lt;/em> 56(2), 2013 — why percentiles, not averages, describe what a user experiences.&lt;/li>
&lt;li>Binance, &lt;a href="https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams">WebSocket Streams — &amp;ldquo;How to manage a local order book correctly&amp;rdquo;&lt;/a> — a public specification of snapshot + increments joined at a sequence number.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Next: &lt;a href="https://kaguc.com/blog/order-reliability/">Order reliability&lt;/a> — why exactly-once doesn&amp;rsquo;t exist, and what to do instead.&lt;/em>&lt;/p></description></item><item><title>Shipping a database with your Flutter app: offline catalogs and instant search</title><link>https://kaguc.com/blog/flutter-offline-store/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/flutter-offline-store/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/flutter-offline-store-zh/">中文&lt;/a>. Flutter implementation notes for &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>; the principle is in &lt;a href="https://kaguc.com/blog/local-first-sync/">Local-first data&lt;/a>.&lt;/em>&lt;/p>
&lt;p>The design from &lt;a href="https://kaguc.com/blog/local-first-sync/">the local-first article&lt;/a> — ship a snapshot, sync deltas by version — has a specific and slightly fiddly Flutter implementation. Here it is concretely.&lt;/p>
&lt;h2 id="choosing-the-store">Choosing the store&lt;/h2>
&lt;p>&lt;code>sqflite&lt;/code> gives you SQLite with raw SQL. &lt;code>drift&lt;/code> sits on top of it (or on its own engine) and adds typed queries, migrations, and first-class isolate support. For a catalog with a schema you will evolve, drift&amp;rsquo;s migration story alone justifies it; for a handful of key-value settings, &lt;code>shared_preferences&lt;/code> and nothing else is correct.&lt;/p>
&lt;p>The mistake worth avoiding is reaching for a document store because it looks simpler, then discovering you need prefix search over several hundred thousand rows with ranking. That is a relational problem with a mature answer, and SQLite is on the device already.&lt;/p>
&lt;h2 id="bundle-the-snapshot-copy-once">Bundle the snapshot, copy once&lt;/h2>
&lt;p>Ship the prebuilt database as an asset and copy it into the app&amp;rsquo;s documents directory on first launch. The copy is necessary because assets are read-only and SQLite needs a writable file.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="n">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">File&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">ensureCatalog&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">dir&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">getApplicationDocumentsDirectory&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">file&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">File&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dir&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;catalog.db&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">shippedVersion&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">812&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// bumped when you re-cut the asset
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">await&lt;/span> &lt;span class="n">file&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">exists&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">_dataVersion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">file&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="n">shippedVersion&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">file&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// user&amp;#39;s copy is newer — keep it
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">bytes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">rootBundle&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;assets/db/catalog.db&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">file&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">writeAsBytes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bytes&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">buffer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">asUint8List&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nl">flush:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">file&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The comparison in the middle is the part people get wrong. After an app update you must not blindly overwrite: a user who has been syncing deltas for months may hold a &lt;em>newer&lt;/em> catalog than the one you just shipped. Copy only when the bundled snapshot is actually ahead, and let delta sync handle the rest.&lt;/p>
&lt;p>The other detail is that the copy happens on first launch, which is the moment you least want a long block. Copying tens of megabytes takes real time, so do it off the UI isolate and show a determinate first-run state rather than a frozen splash.&lt;/p>
&lt;h2 id="two-versions-never-conflated">Two versions, never conflated&lt;/h2>
&lt;p>Keep these separate, as the principle article insists:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Data version&lt;/strong> — how fresh the contents are. It moves when the server publishes changes, and drives delta sync. Store it in a metadata table you control.&lt;/li>
&lt;li>&lt;strong>Schema version&lt;/strong> — the shape of the tables. It moves when &lt;em>your app&lt;/em> changes, and drives migrations. SQLite&amp;rsquo;s &lt;code>user_version&lt;/code>, or drift&amp;rsquo;s &lt;code>schemaVersion&lt;/code>, is the natural home.&lt;/li>
&lt;/ul>
&lt;p>Conflating them produces one of two failures: an app update that discards a synced catalog to reinstall the snapshot, or a migration that never runs because the version counter was busy tracking data.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">MigrationStrategy&lt;/span> &lt;span class="kd">get&lt;/span> &lt;span class="n">migration&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">MigrationStrategy&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nl">onUpgrade:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">from&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">to&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">from&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="m">3&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">addColumn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">instruments&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">instruments&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">board&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// data version untouched — the catalog contents survive
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="search-fts-plus-the-columns-you-actually-search">Search: FTS, plus the columns you actually search&lt;/h2>
&lt;p>Instant search over a few hundred thousand rows is what SQLite&amp;rsquo;s full-text search is for. Create an FTS5 table alongside the main one and query it, rather than running &lt;code>LIKE '%...%'&lt;/code>, which cannot use an index and degrades linearly.&lt;/p>
&lt;p>The interesting part is multilingual matching. Users search a Chinese instrument by its Chinese name, its English name, its ticker, its pinyin, or the initials of its pinyin — and they expect all five to work. That is not a tokenizer problem; it is a &lt;em>data&lt;/em> problem. Generate the variants at build time and store them as searchable columns:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>column&lt;/th>
&lt;th>example&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>symbol&lt;/code>&lt;/td>
&lt;td>&lt;code>00700&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>name_zh&lt;/code>&lt;/td>
&lt;td>腾讯控股&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>name_en&lt;/code>&lt;/td>
&lt;td>Tencent Holdings&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>pinyin&lt;/code>&lt;/td>
&lt;td>tengxunkonggu&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>initials&lt;/code>&lt;/td>
&lt;td>txkg&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Then one FTS query with prefix matching covers every entry style, and ranking is a matter of ordering by which column matched and by a popularity score you ship with the catalog. Doing this at build time rather than on the device keeps startup free and makes the behavior identical across platforms.&lt;/p>
&lt;h2 id="keep-queries-off-the-ui-isolate">Keep queries off the UI isolate&lt;/h2>
&lt;p>A query over a large table will block whatever isolate runs it, and on the UI isolate that means dropped frames during typing — the worst possible place, since search-as-you-type is exactly when responsiveness is judged.&lt;/p>
&lt;p>Drift supports running the database in a background isolate, which is the clean answer (drift 2.x, &lt;code>NativeDatabase.createInBackground&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">db&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AppDatabase&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">NativeDatabase&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">createInBackground&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">file&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then debounce input (a query per keystroke is wasted work), cancel superseded queries, and keep result sets small with &lt;code>LIMIT&lt;/code> — nobody scrolls past the first few dozen matches, and fetching a thousand rows to display twenty is pure cost.&lt;/p>
&lt;h2 id="sync-deltas-not-the-world">Sync deltas, not the world&lt;/h2>
&lt;p>Once the snapshot is in place, the client only asks what changed:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">since&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">meta&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">dataVersion&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">delta&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">api&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">catalogChanges&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">since:&lt;/span> &lt;span class="n">since&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">transaction&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">upsertAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">delta&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">changed&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">db&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">deleteAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">delta&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">removed&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">meta&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">setDataVersion&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">delta&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">version&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// same transaction, always
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The transaction boundary is the whole correctness argument: apply the rows and advance the marker atomically, or a crash between them leaves you permanently skipping a range. Express deltas as upserts and tombstones so re-applying an overlapping range is harmless.&lt;/p>
&lt;h2 id="what-stays-on-the-server">What stays on the server&lt;/h2>
&lt;p>The catalog is safe to hold locally because it is public, slow-moving, and read-only. Balances, positions, and order status are none of those things. Cache them for display if you like, but they are a rendering of a remote fact — refresh on resume, and never let a locally cached number participate in a decision.&lt;/p>
&lt;p>The user&amp;rsquo;s own data — a watchlist, notes — is a third category: small, user-owned, and written on the device. Give it its own table and its own two-way sync rather than mixing writes into the replicated catalog, which keeps the big replication one-way and conflict-free.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;em>API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).&lt;/em>&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://pub.dev/packages/drift">&lt;code>drift&lt;/code>&lt;/a> and its &lt;a href="https://drift.simonbinder.eu/isolates/">Isolates&lt;/a> guide — &lt;code>NativeDatabase.createInBackground&lt;/code>, &lt;code>DriftIsolate&lt;/code>, &lt;code>computeWithDatabase&lt;/code>; &lt;a href="https://pub.dev/packages/sqflite">&lt;code>sqflite&lt;/code>&lt;/a> for the raw-SQL alternative.&lt;/li>
&lt;li>SQLite, &lt;a href="https://www.sqlite.org/fts5.html">FTS5 Extension&lt;/a>; &lt;a href="https://www.sqlite.org/optoverview.html">The LIKE optimization&lt;/a> — why &lt;code>LIKE '%…%'&lt;/code> cannot use an index; &lt;a href="https://www.sqlite.org/pragma.html">&lt;code>PRAGMA user_version&lt;/code>&lt;/a> — the natural home for the schema version.&lt;/li>
&lt;li>M. Kleppmann, A. Wiggins, P. van Hardenberg, M. McGranaghan, &lt;a href="https://www.inkandswitch.com/local-first/">&amp;ldquo;Local-first software: You own your data, in spite of the cloud&amp;rdquo;&lt;/a>, Ink &amp;amp; Switch / Onward! 2019 — the source of the term and the seven ideals behind it.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/flutter-chart-rendering/">Charts in Flutter&lt;/a> · Next: &lt;a href="https://kaguc.com/blog/flutter-project-structure/">Project structure and add-to-app&lt;/a>&lt;/em>&lt;/p></description></item><item><title>The client security model: an input, never an authority</title><link>https://kaguc.com/blog/client-security-model/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/client-security-model/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/client-security-model-zh/">中文&lt;/a>. Part 6 of &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>.&lt;/em>&lt;/p>
&lt;p>Start from an uncomfortable premise: the device is not yours. It belongs to someone who may be curious, compromised, or hostile, and who can inspect memory, intercept traffic, patch the binary, and replay any request your app can make.&lt;/p>
&lt;p>That premise is not pessimism. It is the only assumption that produces a design that still holds when it turns out to be true.&lt;/p>
&lt;h2 id="the-one-rule">The one rule&lt;/h2>
&lt;p>&lt;strong>The client is an input, never an authority.&lt;/strong>&lt;/p>
&lt;p>Every security-relevant decision — can this user do this, what does it cost, what are the limits, is this allowed right now — is made on the server, from server-held state. The client may &lt;em>also&lt;/em> check, for a fast and pleasant experience, but its check is a convenience, and the server must behave identically whether or not the client bothered.&lt;/p>
&lt;p>The test is direct: if someone crafted your app&amp;rsquo;s requests by hand, ignoring every check in your UI, would anything bad happen? If yes, that check was load-bearing, and load-bearing checks cannot live on a device you don&amp;rsquo;t control.&lt;/p>
&lt;p>This is the same principle as &lt;a href="https://kaguc.com/blog/order-reliability/">orders being confirmed only by the server&lt;/a>, applied to authorization instead of state. Client-side validation is user experience. Server-side validation is security. They look similar in code and are not the same thing at all.&lt;/p>
&lt;h2 id="client-hardening-buys-time-not-prevention">Client hardening buys time, not prevention&lt;/h2>
&lt;p>Obfuscation, root and jailbreak detection, integrity checks, anti-debugging, encrypted local storage — these are worth shipping, and it is worth being precise about what they do.&lt;/p>
&lt;p>They raise the cost and the time of an attack. They do not prevent it. Every one of them runs on the attacker&amp;rsquo;s hardware, and anything that runs there can be found and removed by someone patient enough.&lt;/p>
&lt;p>The useful framing: hardening is a &lt;strong>rate limiter on attacker effort&lt;/strong>. That&amp;rsquo;s valuable — most attacks are opportunistic, and cost is a real deterrent. It becomes dangerous only when someone starts treating it as a guarantee, and designs a server that trusts a client which &amp;ldquo;proved&amp;rdquo; it wasn&amp;rsquo;t tampered with. Then a single bypass unlocks everything, because the real control was never there.&lt;/p>
&lt;p>So: layer the defenses, assume each layer fails, and make sure the thing behind them is a server that would have said no anyway.&lt;/p>
&lt;h2 id="there-are-no-secrets-in-a-binary">There are no secrets in a binary&lt;/h2>
&lt;p>Anything shipped inside the app is readable. Strings, keys, endpoints, certificates, the clever thing you spread across three files and XOR&amp;rsquo;d — all recoverable by someone with a disassembler and an afternoon.&lt;/p>
&lt;p>The consequences are concrete:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>No API secrets in the app.&lt;/strong> A shared secret embedded in a client is a public secret with extra steps. If a credential grants privilege, it cannot ship in the binary.&lt;/li>
&lt;li>&lt;strong>Authenticate to get short-lived tokens.&lt;/strong> The app proves identity with something the &lt;em>user&lt;/em> supplies, then holds a token that is narrow in scope and short in life. What leaks then is a token that expires, not a key that doesn&amp;rsquo;t.&lt;/li>
&lt;li>&lt;strong>Sign on the server.&lt;/strong> If a request must be signed with a key, the key belongs where the server is.&lt;/li>
&lt;li>&lt;strong>Use the platform keystore&lt;/strong> for tokens at rest, and still assume a rooted device can reach them. Keystore raises cost; it doesn&amp;rsquo;t make the device trustworthy.&lt;/li>
&lt;/ul>
&lt;p>Encrypting a secret in the binary just means the decryption key is also in the binary. That is not a criticism of the technique — it&amp;rsquo;s the definition of the situation.&lt;/p>
&lt;h2 id="the-effective-policy-is-the-lowest-layer">The effective policy is the lowest layer&lt;/h2>
&lt;p>A rule worth generalizing beyond security: &lt;strong>a system does what its lowest, most specific configuration says, not what its top-level declaration claims.&lt;/strong>&lt;/p>
&lt;p>Platforms have layered policy — a manifest declaration, a configuration file, a per-domain override, a build-type variant. When they disagree, the more specific layer wins, and it is entirely possible for a reassuring top-level flag to be silently negated by something underneath it. A team can read the top line, conclude the system is safe, and be wrong for years, because nothing errors — the override is a legitimate feature.&lt;/p>
&lt;p>The practice that follows: &lt;strong>audit effective behavior, not declared intent.&lt;/strong> Read the final merged configuration for the build you actually ship. Verify with observation — inspect real traffic, confirm the runtime state — rather than reading the declaration and trusting it. &amp;ldquo;What did we declare&amp;rdquo; and &amp;ldquo;what does it do&amp;rdquo; are different questions, and only the second one has consequences.&lt;/p>
&lt;h2 id="every-entry-point-is-an-undocumented-api">Every entry point is an undocumented API&lt;/h2>
&lt;p>The attack surface isn&amp;rsquo;t only the network. Anything another app or a web page can invoke is an interface you are exposing, whether or not you documented it:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Exported components and deep links.&lt;/strong> If another app can launch your screen with parameters, those parameters are attacker-controlled input. Validate the scheme, host, and path strictly against an allowlist; never let a link alone authorize a sensitive action; and require the session to be re-checked at the destination rather than assuming the caller was legitimate.&lt;/li>
&lt;li>&lt;strong>Web-to-native bridges.&lt;/strong> A bridge exposes a set of callable functions to whatever page is loaded. That set &lt;em>is&lt;/em> a public API with the privileges of your app. Restrict which origins can use it, keep the surface minimal, and validate every argument as hostile input. A bridge that can reach account or payment functionality should assume the page calling it is malicious.&lt;/li>
&lt;li>&lt;strong>Custom URL schemes and inter-app messaging.&lt;/strong> Same reasoning: you cannot verify who is calling, so the call must not be the authorization.&lt;/li>
&lt;/ul>
&lt;p>Each of these is a place where a feature (&amp;ldquo;let partners deep-link into the app&amp;rdquo;) quietly becomes an interface, and interfaces need threat models.&lt;/p>
&lt;h2 id="transport-and-the-pinning-trade">Transport, and the pinning trade&lt;/h2>
&lt;p>Encrypt everything, and be sure of it — the failure mode here is not &amp;ldquo;we forgot TLS,&amp;rdquo; it&amp;rsquo;s a narrow exception for one legacy endpoint that quietly becomes a global allowance.&lt;/p>
&lt;p>Certificate pinning is a genuine trade rather than a best practice. It defeats interception with an installed root, which is the realistic attack on a user&amp;rsquo;s device. It also means a certificate rotation you didn&amp;rsquo;t plan for can brick every installed copy of your app. If you pin: pin to an intermediate or include backup pins, ship a remote kill switch, and rehearse rotation before you need it. If you can&amp;rsquo;t operate that reliably, standard TLS validation done properly is better than pinning done fragilely.&lt;/p>
&lt;h2 id="scaling-this-down">Scaling this down&lt;/h2>
&lt;p>Most of the above is proportional to what an attacker gains. If your app holds public data and reversible actions, exotic hardening is the wrong place to spend.&lt;/p>
&lt;p>What is &lt;em>not&lt;/em> optional at any scale, because it&amp;rsquo;s where real incidents come from: authorization enforced on the server for every request, no secrets in the binary, TLS with no exceptions, short-lived scoped tokens, and treating every deep link and bridge call as hostile input.&lt;/p>
&lt;p>The unglamorous list is the one that matters. Breaches are rarely elegant — they are a forgotten endpoint that trusts a client-supplied user id, a debug flag that shipped, an internal address left reachable. Get those right first, and add hardening because you have something worth the attacker&amp;rsquo;s time, not because it feels rigorous.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>J. H. Saltzer, M. D. Schroeder, &lt;a href="https://web.mit.edu/Saltzer/www/publications/protection/">&amp;ldquo;The Protection of Information in Computer Systems&amp;rdquo;&lt;/a>, &lt;em>Proc. IEEE&lt;/em> 63(9), 1975 — complete mediation (check every access at the authority) and open design (no security in a secret mechanism).&lt;/li>
&lt;li>OWASP, &lt;a href="https://mas.owasp.org/MASVS/">Mobile Application Security Verification Standard (MASVS)&lt;/a> — the control groups this article compresses: storage, crypto, auth, network, platform interaction, resilience.&lt;/li>
&lt;li>OWASP, &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Pinning_Cheat_Sheet.html">Pinning Cheat Sheet&lt;/a> — when pinning helps, when it bricks you, and how to rotate.&lt;/li>
&lt;li>Android developers, &lt;a href="https://developer.android.com/privacy-and-security/security-config">Network security configuration&lt;/a> — a concrete example of layered policy where the per-domain entry overrides the base config.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/modular-evolution/">Modularity and migration&lt;/a> · Back to &lt;a href="https://kaguc.com/blog/trading-app-architecture/">the overview&lt;/a>.&lt;/em>&lt;/p></description></item><item><title>The cross-platform boundary: share what must not diverge</title><link>https://kaguc.com/blog/cross-platform-boundary/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/cross-platform-boundary/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/cross-platform-boundary-zh/">中文&lt;/a>. Part 4 of &lt;a href="https://kaguc.com/blog/trading-app-architecture/">Trading app architecture&lt;/a>.&lt;/em>&lt;/p>
&lt;p>The cross-platform debate is usually conducted as a framework comparison, which is why it never resolves. The prior question decides most of it: &lt;strong>which parts of your app must be identical on iOS and Android, and which parts should not be?&lt;/strong>&lt;/p>
&lt;p>Answer that and the framework choice narrows to a couple of reasonable options. Skip it and no amount of benchmarking will help, because you are arguing about tools while the requirement is undefined.&lt;/p>
&lt;h2 id="the-test-is-divergence-a-bug-or-a-feature">The test: is divergence a bug or a feature?&lt;/h2>
&lt;p>Take any layer of your app and ask what happens if the two platforms behave differently.&lt;/p>
&lt;p>For an order validator, a fee calculation, an options pricing rule, a state machine, a protocol encoder: divergence is a &lt;strong>defect&lt;/strong>. Two independent implementations of the same financial rule &lt;em>will&lt;/em> drift — not maybe, will. They drift on the edge cases first, which in this domain are also the expensive cases. Nobody notices for months, and the bug report arrives as a customer whose numbers didn&amp;rsquo;t match.&lt;/p>
&lt;p>For navigation transitions, gesture physics, date pickers, permission flows, accessibility, share sheets: divergence is &lt;strong>correct&lt;/strong>. Users have platform expectations, and code that flattens them produces an app that feels subtly wrong on both. Effort spent unifying this layer buys negative value.&lt;/p>
&lt;p>That&amp;rsquo;s the whole rule: &lt;strong>share the layer where difference is a defect, keep native the layer where difference is the point.&lt;/strong>&lt;/p>
&lt;p>Notice this is a correctness argument, not an efficiency one. DRY is usually justified as &amp;ldquo;write it once,&amp;rdquo; which invites the counterargument that duplication is sometimes cheaper. That counterargument is fine for UI and irrelevant for domain rules, because there the cost of duplication isn&amp;rsquo;t typing — it&amp;rsquo;s two divergent truths about money.&lt;/p>
&lt;h2 id="what-sharing-actually-costs">What sharing actually costs&lt;/h2>
&lt;p>Sharing is not free, and the costs are structural rather than obvious:&lt;/p>
&lt;p>&lt;strong>A boundary appears.&lt;/strong> Shared code and platform code must talk. That interface needs types both sides understand, serialization, and error mapping. It is now a thing to design and version.&lt;/p>
&lt;p>&lt;strong>A runtime arrives.&lt;/strong> A second runtime means memory, startup cost, and a debugger that no longer sees the whole picture. When something breaks &lt;em>at&lt;/em> the boundary, you are debugging two systems and the seam between them.&lt;/p>
&lt;p>&lt;strong>The team splits.&lt;/strong> Someone must be fluent in the shared layer and its toolchain. At small scale that is one person, which is a bus factor rather than a plan.&lt;/p>
&lt;p>So the question isn&amp;rsquo;t &amp;ldquo;does sharing help&amp;rdquo; — it&amp;rsquo;s &amp;ldquo;does the divergence risk in this layer exceed the boundary cost.&amp;rdquo; For domain logic, usually yes. For a settings screen, usually no.&lt;/p>
&lt;h2 id="the-three-positions">The three positions&lt;/h2>
&lt;p>&lt;strong>Fully native, twice.&lt;/strong> Maximum platform fidelity, zero boundary cost, and every rule written twice. Reasonable when the shared logic is thin or the platform surface is most of the product.&lt;/p>
&lt;p>&lt;strong>Share the logic, keep native UI.&lt;/strong> The domain, models, and validation live in one codebase; each platform renders it. This targets exactly the layer where divergence is a defect and leaves the layer where it isn&amp;rsquo;t. The cost is that boundary and its toolchain.&lt;/p>
&lt;p>&lt;strong>Share the UI too.&lt;/strong> One codebase draws both platforms. Fastest to a feature-complete app with one team, and the reason most small teams should start here. The cost arrives later and from a predictable direction: platform capabilities. Every camera, biometric, background, or notification feature eventually needs a plugin, and a plugin is native code with extra steps. Budget for that from the start instead of being surprised by it.&lt;/p>
&lt;h2 id="the-mixed-model-and-what-it-really-costs">The mixed model, and what it really costs&lt;/h2>
&lt;p>Large apps often end up mixed: a native shell with a cross-platform UI framework embedded for some screens. This is usually not indecision — it is the honest shape of a migration, and it&amp;rsquo;s the right pattern when you cannot stop shipping to rewrite (&lt;a href="https://kaguc.com/blog/modular-evolution/">more on that next&lt;/a>).&lt;/p>
&lt;p>It has real costs worth naming before you choose it:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Runtime memory.&lt;/strong> Each embedded engine has a footprint. Multiple live engines multiply it, and the mitigation — caching and reusing engines — is itself a lifecycle problem you now own.&lt;/li>
&lt;li>&lt;strong>Two navigation stacks.&lt;/strong> The native stack and the framework&amp;rsquo;s router must be unified, or back-button behavior becomes a permanent source of bugs.&lt;/li>
&lt;li>&lt;strong>State on both sides.&lt;/strong> Session, theme, and locale now exist twice and must be kept in agreement. Every duplicated piece of state is a future desync.&lt;/li>
&lt;li>&lt;strong>Debugging across runtimes.&lt;/strong> A stack trace stops at the boundary. Crash and performance tooling must be configured to see both sides, or half your incidents become guesswork.&lt;/li>
&lt;/ul>
&lt;h2 id="make-the-boundary-coarse">Make the boundary coarse&lt;/h2>
&lt;p>Whatever you share, the interface between the shared core and the platform should be &lt;strong>coarse-grained&lt;/strong>.&lt;/p>
&lt;p>The cost of crossing a boundary — serialization, marshalling, a runtime transition — is paid per &lt;em>call&lt;/em>, not per byte. A chatty interface with dozens of small property reads can be dramatically slower than one call returning a complete object, even though it moves less data. This is the same lesson as batching updates at a native boundary in &lt;a href="https://kaguc.com/blog/realtime-data-pipeline/">part one&lt;/a>, and it generalizes: &lt;strong>design boundaries as few, meaningful operations, not as remote field access.&lt;/strong>&lt;/p>
&lt;p>There&amp;rsquo;s a design benefit too. A coarse interface expresses intent (&amp;ldquo;place this order,&amp;rdquo; &amp;ldquo;give me this screen&amp;rsquo;s data&amp;rdquo;), which stays stable while implementations change underneath. A fine-grained interface exposes structure, and structure changes constantly.&lt;/p>
&lt;h2 id="choosing-honestly">Choosing, honestly&lt;/h2>
&lt;p>Two variables decide it: how much of your product is &lt;strong>domain logic that must not diverge&lt;/strong>, and how many &lt;strong>people&lt;/strong> you have.&lt;/p>
&lt;ul>
&lt;li>One small team, mostly UI over an API: share everything. One codebase, native modules where the platform demands it.&lt;/li>
&lt;li>Small team, meaningful domain logic: share everything, but keep that logic in its own layer with no UI dependencies — so it can be extracted later without a rewrite.&lt;/li>
&lt;li>Multiple platform teams, substantial domain logic: share the logic, keep UI native. This is where the boundary cost is clearly repaid.&lt;/li>
&lt;li>Single platform: the question does not exist. Don&amp;rsquo;t pay for optionality you have no evidence you&amp;rsquo;ll use.&lt;/li>
&lt;/ul>
&lt;p>The mistake that costs the most is choosing a sharing strategy for a team size you don&amp;rsquo;t have yet. Architecture that anticipates an organization you might build is how small teams end up maintaining a large team&amp;rsquo;s overhead with a small team&amp;rsquo;s capacity.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>J. Waldo, G. Wyant, A. Wollrath, S. Kendall, &lt;a href="https://scholar.harvard.edu/files/waldo/files/waldo-94.pdf">&amp;ldquo;A Note on Distributed Computing&amp;rdquo;&lt;/a>, Sun Microsystems Laboratories TR-94-29, 1994 — a boundary between address spaces cannot be papered over; latency and partial failure are paid per call.&lt;/li>
&lt;li>A. Hunt, D. Thomas, &lt;a href="https://pragprog.com/titles/tpp20/">&lt;em>The Pragmatic Programmer&lt;/em>&lt;/a>, 1999 — DRY, stated as a rule about knowledge, not about typing.&lt;/li>
&lt;li>M. E. Conway, &lt;a href="https://www.melconway.com/Home/Committees_Paper.html">&amp;ldquo;How Do Committees Invent?&amp;rdquo;&lt;/a>, &lt;em>Datamation&lt;/em>, 1968 — system structure mirrors the communication structure of the organization that built it.&lt;/li>
&lt;li>Flutter docs, &lt;a href="https://docs.flutter.dev/add-to-app">Add Flutter to an existing app&lt;/a> and &lt;a href="https://docs.flutter.dev/add-to-app/multiple-flutters">Multiple Flutter instances&lt;/a> — engine warm-up, &lt;code>FlutterEngineGroup&lt;/code>, and the cost of several live engines.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>Previous: &lt;a href="https://kaguc.com/blog/local-first-sync/">Local-first data&lt;/a> · Next: &lt;a href="https://kaguc.com/blog/modular-evolution/">Modularity and migration&lt;/a>&lt;/em>&lt;/p></description></item><item><title>Trading app architecture: six constraints and what they force</title><link>https://kaguc.com/blog/trading-app-architecture/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://kaguc.com/blog/trading-app-architecture/</guid><description>&lt;p>&lt;em>Also in &lt;a href="https://kaguc.com/blog/trading-app-architecture-zh/">中文&lt;/a>. A six-part series.&lt;/em>&lt;/p>
&lt;p>You cannot pick a trading client&amp;rsquo;s architecture by taste. Most of it is decided before you write a line — by how fast the data changes, what a wrong order costs, how big the catalog is, how many platforms and teams you have, and the fact that the app runs on a device you don&amp;rsquo;t control.&lt;/p>
&lt;p>So the useful question is not &amp;ldquo;which stack.&amp;rdquo; It is: &lt;strong>what does each of those constraints force, and what happens to the design when your version of that constraint is smaller?&lt;/strong>&lt;/p>
&lt;p>That second half is the part worth caring about. Large financial apps converge on a recognizable shape, and the shape is often copied by teams who don&amp;rsquo;t share the constraints that produced it. What follows is the reasoning behind each element — informed by taking a large trading app apart and checking that the theory matched what a team at real scale actually built — and then the conditions under which each element is simply unnecessary.&lt;/p>
&lt;h2 id="the-six-constraints">The six constraints&lt;/h2>
&lt;p>&lt;strong>1. Freshness.&lt;/strong> Market data is a high-rate, unbounded stream whose value decays in milliseconds. It cannot be polled, the consumer is permanently slower than the producer, and a silently dropped update is not missing data but &lt;em>wrong&lt;/em> data. This forces a pushed, sequenced, back-pressured transport, and a decision about which streams may be conflated and which must be resynced.
→ &lt;a href="https://kaguc.com/blog/realtime-data-pipeline/">Real-time data pipelines: push, backpressure, and conflation&lt;/a>&lt;/p>
&lt;p>&lt;strong>2. Irreversibility.&lt;/strong> An order cannot be undone by the user, and exactly-once delivery does not exist. This forces an idempotency key minted and persisted &lt;em>before&lt;/em> the first send, an explicit unknown state on timeout, reconciliation instead of blind retry, and a server that is the only source of truth.
→ &lt;a href="https://kaguc.com/blog/order-reliability/">Order reliability: idempotency, unknown states, and reconciliation&lt;/a>&lt;/p>
&lt;p>&lt;strong>3. A large catalog.&lt;/strong> Hundreds of thousands of instruments, searchable instantly, including offline. This forces separating slow-changing reference data from fast-changing values, shipping a snapshot on the device, and syncing deltas against a monotonic version — while the ledger stays authoritative on the server.
→ &lt;a href="https://kaguc.com/blog/local-first-sync/">Local-first data: snapshots, deltas, and the two kinds of state&lt;/a>&lt;/p>
&lt;p>&lt;strong>4. Two platforms, one truth.&lt;/strong> iOS and Android must agree exactly on financial rules, and should &lt;em>not&lt;/em> agree on interaction idioms. This forces sharing the layer where divergence is a defect and keeping native the layer where divergence is correct — and paying attention to the cost of the boundary between them.
→ &lt;a href="https://kaguc.com/blog/cross-platform-boundary/">The cross-platform boundary: share what must not diverge&lt;/a>&lt;/p>
&lt;p>&lt;strong>5. Many teams, many years.&lt;/strong> The system must keep shipping while it changes shape. This forces module boundaries drawn around decisions likely to change, a core that receives its dependencies rather than reaching for them, and migration by growing the new inside the old instead of rewriting.
→ &lt;a href="https://kaguc.com/blog/modular-evolution/">Modularity and migration: cutting boundaries that survive&lt;/a>&lt;/p>
&lt;p>&lt;strong>6. An untrusted device.&lt;/strong> Every control you ship can be removed. This forces treating the client as an input and never an authority, keeping no secrets in the binary, auditing effective rather than declared policy, and treating every deep link and bridge call as hostile input.
→ &lt;a href="https://kaguc.com/blog/client-security-model/">The client security model: an input, never an authority&lt;/a>&lt;/p>
&lt;h2 id="flutter-implementation-notes">Flutter implementation notes&lt;/h2>
&lt;p>The six above are framework-neutral. This second group takes the same constraints down to a concrete Flutter build — with the patterns, the costs, and a reference design you could start from on Monday.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://kaguc.com/blog/flutter-highfreq-state/">Flutter under a firehose&lt;/a> — per-symbol notifiers, frame coalescing, and making rebuild scope match change scope&lt;/li>
&lt;li>&lt;a href="https://kaguc.com/blog/flutter-native-channels/">Channels, Pigeon, and FFI&lt;/a> — the three ways out of Dart, memory ownership, and why &lt;code>EventChannel&lt;/code> has no backpressure&lt;/li>
&lt;li>&lt;a href="https://kaguc.com/blog/flutter-chart-rendering/">Charts that hold 60fps&lt;/a> — custom painting, static and live layers, and an honest &lt;code>shouldRepaint&lt;/code>&lt;/li>
&lt;li>&lt;a href="https://kaguc.com/blog/flutter-offline-store/">Shipping a database with your app&lt;/a> — bundled SQLite, data version vs schema version, FTS with pinyin search&lt;/li>
&lt;li>&lt;a href="https://kaguc.com/blog/flutter-project-structure/">Project structure and add-to-app&lt;/a> — packages as enforced boundaries, routing as a registry, what embedding costs&lt;/li>
&lt;li>&lt;a href="https://kaguc.com/blog/flutter-trading-mvp/">A reference design: quotes MVP&lt;/a> — a committed plan, what it deliberately doesn&amp;rsquo;t build, and the trigger for each upgrade&lt;/li>
&lt;/ul>
&lt;h2 id="the-decision-rules">The decision rules&lt;/h2>
&lt;p>Every part above ends with &amp;ldquo;adopt it when a measurement demands it,&amp;rdquo; which is meaningless until someone names the measurement. One article supplies the numbers — frame budgets, message rates, catalog sizes, team sizes — the ladder of cheaper fixes to exhaust first, and the counter-signals that should stop you even past a threshold.&lt;/p>
&lt;p>It also draws the distinction the rest of the series glosses over: &lt;strong>correctness has no threshold.&lt;/strong> Idempotency keys, server-side authorization, sequence numbers, and decimal money are day-one work at any scale, because the cost of retrofitting them is unbounded. Only performance decisions wait for a number.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://kaguc.com/blog/adoption-thresholds/">When to adopt the heavy machinery: thresholds, not opinions&lt;/a>&lt;/strong>&lt;/li>
&lt;/ul>
&lt;h2 id="which-of-these-are-actually-yours">Which of these are actually yours?&lt;/h2>
&lt;p>Each requirement came from a constraint, and constraints have magnitude. Shrink one and the requirement it produced collapses into something much smaller:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>If your reality is…&lt;/th>
&lt;th>The requirement…&lt;/th>
&lt;th>becomes…&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Data changes at human speed&lt;/td>
&lt;td>pushed, sequenced, conflated pipeline&lt;/td>
&lt;td>a WebSocket into UI state&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Actions are reversible&lt;/td>
&lt;td>idempotency key, unknown state, reconciliation&lt;/td>
&lt;td>a request with a retry&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Small or server-side catalog&lt;/td>
&lt;td>on-device snapshot + delta sync&lt;/td>
&lt;td>a search endpoint&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>One platform, or thin domain logic&lt;/td>
&lt;td>shared cross-platform core&lt;/td>
&lt;td>build it once, natively&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>One small team&lt;/td>
&lt;td>module registry, ports and adapters&lt;/td>
&lt;td>one codebase, no enforced boundaries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Low-value or public data&lt;/td>
&lt;td>layered hardening&lt;/td>
&lt;td>standard auth and TLS, done properly&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Read the right column. For a small team building a quotes-and-watchlist MVP, this reasoning produces: one cross-platform codebase, REST plus a WebSocket, a search endpoint, no shared-core layer, no native performance work, no custom transport. That is not a simplified version of the large architecture. For those constraints it is the &lt;em>correct&lt;/em> architecture, and the large one would be a mistake.&lt;/p>
&lt;p>The large app is not more advanced. It is the same six constraints at full magnitude, plus years of accumulated migration state that no one would choose deliberately.&lt;/p>
&lt;h2 id="how-to-use-this">How to use this&lt;/h2>
&lt;p>Write down your own six magnitudes honestly. Work out what each one forces. Build only that.&lt;/p>
&lt;p>Then adopt each heavy element on the day a &lt;em>measured&lt;/em> constraint demands it — the queue actually grows, the profile actually points at the decode loop, the merge conflicts actually recur — and not one sprint earlier. Most of the cost of a large architecture is paid by teams who adopted it in anticipation of a scale they never reached.&lt;/p>
&lt;p>What compounds is the map from constraints to structure. The structure itself is disposable, and someone else&amp;rsquo;s structure, copied, is a liability with good PR.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>D. L. Parnas, &lt;a href="https://doi.org/10.1145/361598.361623">&amp;ldquo;On the Criteria To Be Used in Decomposing Systems into Modules&amp;rdquo;&lt;/a>, &lt;em>CACM&lt;/em> 15(12), 1972 — a module should hide a decision that is likely to change.&lt;/li>
&lt;li>M. E. Conway, &lt;a href="https://www.melconway.com/Home/Committees_Paper.html">&amp;ldquo;How Do Committees Invent?&amp;rdquo;&lt;/a>, &lt;em>Datamation&lt;/em>, 1968 — system structure mirrors the communication structure of the organization that built it.&lt;/li>
&lt;li>J. H. Saltzer, D. P. Reed, D. D. Clark, &lt;a href="https://web.mit.edu/Saltzer/www/publications/endtoend/endtoend.pdf">&amp;ldquo;End-to-End Arguments in System Design&amp;rdquo;&lt;/a>, &lt;em>ACM TOCS&lt;/em> 2(4), 1984 — a guarantee is only real at the endpoint that can verify it.&lt;/li>
&lt;li>M. Kleppmann, &lt;a href="https://dataintensive.net/">&lt;em>Designing Data-Intensive Applications&lt;/em>&lt;/a>, O&amp;rsquo;Reilly, 2017 — ch. 5 (replication) and ch. 11 (stream processing) are the background for the catalog and feed designs.&lt;/li>
&lt;li>J. Bezos, &lt;a href="https://s2.q4cdn.com/299287126/files/doc_financials/annual/2015-Letter-to-Shareholders.PDF">Amazon 2015 Letter to Shareholders&lt;/a>, 2016 — Type 1 (one-way door) vs Type 2 (two-way door) decisions.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;p>&lt;em>The series is grounded in a static architectural analysis of a large trading app: what layers exist and how they communicate. No running app, no source recovery, no private protocols — and the detailed teardown notes stay out of the series deliberately, because the transferable part is the reasoning, not the artifact.&lt;/em>&lt;/p></description></item></channel></rss>