August 30, 2026 · 19 min read
Flutter for the Interface, Rust for the Core: A Cross-Language Architecture in Practice
Starting from a Flutter app that gradually became harder to maintain, this article explains why I introduced a Rust Core, what FRB does between the two sides, and how the architecture creates clearer boundaries.
How a Flutter app gradually becomes heavy
Flutter makes it easy to feel that one codebase can do everything.
Fast development is not the problem.
We build screens in Flutter, put state in a controller, send network requests through a service, and store local data in a database. When we need file access, audio, or a system capability, we add a plugin. Early in a project, this path is smooth and features arrive quickly.
My music app started in much the same way.
Over time, it gained more music sources, library scanning, metadata extraction, search, a playback queue, lyrics, caching, session recovery, and background playback. Controllers that originally owned screen state gradually learned about databases, networking, the player, and other controllers. A single tap could trigger several asynchronous jobs and listeners.
What really became unclear was the state boundary.
The code still worked, but some simple questions became increasingly difficult to answer:
- Which object is authoritative for the current playback queue?
- If the screen shows success, has the data actually been committed?
- If synchronization fails halfway through, is the previous data still trustworthy?
- Which state should return after the app restarts?
- Can editing one source accidentally trigger a refresh somewhere else?
I tried splitting controllers into more services and helper files. The individual files became smaller, but the call graph did not. Many objects still shared the same mutable state. One large file had simply turned into a group of smaller files that all knew one another.
The project did not need more directories. It needed a boundary that was harder to cross casually.
Why I introduced Rust
When people hear about adding Rust to Flutter, performance is usually the first benefit that comes to mind: move computationally expensive work to Rust and keep the interface in Dart.
Performance matters, but it was not the main reason for this redesign.
If I only rewrote a parser in Rust and called it through FFI, the application structure would remain mostly unchanged. A controller could still own UI state, database access, and business decisions. The call graph would merely contain one more language.
What I wanted was a Core that could exist independently of Flutter.
This Core does not know about widgets, BuildContext, routes, or dialogs. It knows the entities, state, and rules of the application: when a synchronization is complete, how an operation is committed, how a queue changes, and what remains valid after a failure.
The boundary becomes an engineering fact, not just a convention.
In a Dart-only project, we can certainly say that the domain layer must not depend on the UI. Under deadline pressure, however, one convenient import or one Get.find() can gradually weaken that rule. An independent Rust crate cannot import a Flutter widget at all. A reverse dependency fails at build time.
The Core no longer waits for the UI to start.
I can run Rust tests directly. I can also use a CLI to open the database, diagnose a source, scan files, and verify state transitions. Problems that once required repeated tapping in a simulator can become one command or one deterministic test.
Complex state finally has one clear owner.
One writer commits database synchronization. Playback commands enter one ordered queue. Flutter no longer maintains another version of the same core state. Removing that duplicate state also removes an entire class of synchronization problems.
This is why I did not introduce Rust because Dart was incapable. I introduced it because I wanted the Core to have four properties:
The Core should be independent, stable, testable, and reachable only through explicit entry points.
Rust is a good fit for that role. I wrote more about why the language works well for enforcing safety boundaries in my notes on Rust’s safety and performance tradeoffs.
The architecture is simpler than it first appears
Ignoring the layer names for a moment, there are only three main participants:
Flutter UI <-> FRB <-> Rust Core
I think of Flutter as the cockpit. It receives user input and displays speed, direction, and current status, but it does not decide how combustion happens inside the engine.
The Rust Core is closer to the engine and control system. It owns the real business state, applies the rules, and returns confirmed results to the interface.
FRB is the wiring between them. It carries commands from Flutter into Rust and converts Rust results back into Dart objects.
| Participant | What it cares about most |
|---|---|
| Flutter | What the user did and what the screen should display |
| FRB | How commands and results cross the language boundary |
| Rust Core | How business state changes safely and deterministically |
A more complete call path looks like this:
User action or system event
↓
Flutter View / ViewModel
↓
Dart Repository / Facade
↓
flutter_rust_bridge
↓
Rust API
↓
Rust Core
↓
Database, network, filesystem, or audio engine
The result travels back in the opposite direction and eventually becomes state that Flutter can render.
The Rust Core here is not a server backend. It is a local native core packaged inside the app alongside Flutter. A call does not cross the network; it only crosses the Dart–Rust language boundary.
For a Flutter developer, the most visible changes are these:
| What often happened before | After introducing a Rust Core |
|---|---|
| Screen state and business state were mixed | Flutter shapes presentation state; business facts come from Rust |
| Debugging business behavior required the full app | The Core can be tested and run from the command line |
| Several controllers could mutate the same data | Important state has one explicit writer |
| Replacing storage or another implementation affected many screens | Implementation stays behind the Core while Flutter keeps the same contract |
The layers, bridge contracts, and synchronization mechanisms later in this article all serve these four changes.
Flutter owning the UI does not mean leaving only widgets
“Flutter only handles UI” can sound extreme, as though no Dart code may exist outside a widget.
My actual boundary is not that rigid.
Flutter still owns the work it does best:
- screens, components, animation, and responsive layout;
- routing, dialogs, and temporary input;
- loading, empty, and error presentation states;
- shaping Rust results into a form that is convenient for a screen;
- turning taps, gestures, refreshes, and platform callbacks into commands.
ViewModels still exist. They answer “what should this screen display now?” They no longer answer “what is the authoritative business truth?”
For example, after a user taps Play, Flutter may immediately display a pending state. It does not announce that playback has started. The Rust Core confirms the actual result, and Flutter updates the button and progress only after receiving the new state.
Tap Play
→ Flutter shows pending
→ Rust checks the queue and media resource
→ playback state changes
→ Rust returns a confirmed snapshot
→ Flutter shows playing or an error
The distinction looks small, but it matters.
Previously, the screen and player state could change independently and listeners would try to keep them aligned. Now Flutter displays the result of the Core instead of making another independent decision.
What belongs in the Rust Core
I do not use “everything outside the UI goes into Rust” as the rule. A more useful question is whether the behavior remains valid after replacing the interface.
| Better suited to the Rust Core | Better kept in Flutter or at the platform edge |
|---|---|
| Stable business models and validation | Navigation, animation, and layout |
| Synchronization, merging, search, and indexing | System permission dialogs |
| Database transactions and migrations | Keychain and Keystore entry points |
| Queue, task, download, and playback state machines | AirPlay, CarPlay, and lock-screen controls |
| Timeout, retry, cancellation, and recovery policy | Temporary state that affects only one screen |
| File parsing, media processing, and heavy computation | Platform audio focus and similar interactions |
Rust can express “I need this credential” or “playback was interrupted by the system.” It does not need to know which iOS class produced the callback. The platform side implements the capability and exposes a small interface to the Core.
I still divide the Rust side into layers, but the names are not the important part:
domain stable entities, rules, and state transitions
application coordination of a complete business operation
ports capabilities required by the Core
infrastructure database, network, file, and audio implementations
api the bridge-facing entry point for Flutter
The important rule is that core business behavior depends on neither Flutter nor FRB.
FRB is only the outer door. A CLI or another host may use a different door without rebuilding the rules inside the house. This is close to the Core/Variant layering approach I used elsewhere: stable capabilities should not be copied together with each product shell.
What FRB does for me
flutter_rust_bridge, or FRB, generates bindings between Dart/Flutter and Rust.
We write ordinary Rust functions, structs, and enums. FRB generates the Dart API and the conversion code between the two languages. An asynchronous Rust function can be used like a regular Dart Future, and structured Rust results become Dart objects.
In practical terms, it handles three repetitive jobs:
- generating Dart calls from Rust interfaces;
- converting parameters and return values across the language boundary;
- fitting synchronous and asynchronous Rust calls into familiar Dart usage.
A simplified Rust function might look like this:
pub async fn search_catalog(
session_id: String,
query: String,
offset: u32,
limit: u16,
) -> CatalogSearchResult {
// Call the Core and map the result into a bridge type.
}
Flutter sees something closer to this:
final result = await core.searchCatalog(
sessionId: sessionId,
query: keyword,
offset: 0,
limit: 30,
);
Without FRB, I would have to maintain lower-level FFI functions, memory handling, and type conversion myself. FRB absorbs that repetitive work and makes cross-language calls feel much closer to ordinary Dart development.
FRB builds the bridge. We still decide what should travel across it.
I eventually limited the bridge to a small set of concepts:
| Value | Purpose |
|---|---|
| Command | Ask the Core to do something, such as synchronize, play, or pause |
| Query | Read current data without changing it |
| Snapshot | A complete state already confirmed by the Core |
| Event | A fact that already occurred, such as playback starting or sync completing |
| Failure | A recognizable error category rather than unstable free-form text |
Database connections, HTTP clients, player handles, and URLs containing credentials never cross this bridge. They are Rust implementation details, not business results that Flutter needs to know.
Prefer fewer, more complete bridge operations
When first designing an FRB API, it is tempting to expose every method on a Rust object to Dart: one call for an album, one for its songs, and perhaps more calls for every field.
Why fragmented APIs lose the boundary.
Every call requires scheduling and data conversion. The more fragmented the API becomes, the more likely Flutter is to collect intermediate results and rebuild the business state in Dart. Rust may appear to be the Core, while the real workflow still lives in Flutter.
Express one complete intention at a time.
I now prefer operations such as:
syncCatalog(source)
searchCatalog(query, page)
replacePlaybackQueue(items, startIndex)
seek(position)
currentPlaybackSnapshot()
Flutter tells the Core what it wants. Rust returns what ultimately happened.
Add one handwritten boundary.
This is also why screens do not call FRB-generated code directly. A handwritten Dart facade and repository sit in between:
Widget
→ ViewModel
→ Repository
→ handwritten Facade
→ generated FRB API
These layers may look less direct, but a screen no longer needs to understand session IDs, Rust DTOs, or bridge exceptions. An FRB upgrade or Rust API adjustment can stop near the boundary instead of spreading through the UI.
The biggest benefit: state finally has one source
The most noticeable benefit of this redesign was not speed. It was how much easier a single source of truth became.
A catalog changes version only after complete success.
In the old structure, a remote provider, a Flutter database, and a screen cache might all participate in catalog state. If a failed sync silently fell back to the old writer, it became difficult to explain which data the user was seeing.
After a source moves to Rust, only Rust writes it. A complete synchronization is first prepared in a staging area. When every page and scan succeeds, one transaction commits a new revision. A failure or cancellation leaves the old revision valid.
Existing data: revision 12
↓
Prepare new data in the background
↓
Success: commit revision 13 once
Failure: keep revision 12
Flutter never sees data that is half old and half new, and it does not need to guess how far synchronization progressed.
Playback commands enter one queue.
Play, pause, seek, track changes, and system interruptions all enter one ordered queue in Rust. The Core processes them in sequence and publishes a new playback snapshot. Flutter may cache that snapshot for rendering, but it cannot maintain a second set of queue rules.
Maintaining one less copy of state often improves long-term stability more than optimizing one more function.
The Core can live independently of the app
Debugging no longer starts by launching the app.
Previously, investigating a catalog issue often meant launching the app, preparing the right screen state, navigating to an entry point, and then searching a long log for the actual failure.
With an independent Core, the same capabilities can be called through a Rust CLI:
cd rust
cargo test
cargo run -p imusic_cli -- doctor
The CLI is more than a demonstration. Without Flutter, it can:
- verify whether a source can connect;
- scan a fixed test library;
- inspect database versions and migrations;
- replay a search or synchronization;
- observe playback state-machine transitions.
This also improves the design of the Core. If “core logic” cannot run without a screen, it probably still depends on the UI environment.
Each test layer answers a different question.
Pure rules and state transitions use Rust unit tests. Database, protocol, and file parsing use fixtures. The playback state machine uses a fake audio backend. FRB tests the contract between the two languages. Only after those pass do physical-device tests cover background playback, Bluetooth, interruptions, and lifecycle behavior.
Core rule tests
↓
Real adapter tests
↓
FRB contract tests
↓
Flutter screen tests
↓
iOS / Android device verification
Rust does not make the business logic correct automatically. It makes “has the Core been verified independently of the UI?” a much easier question to answer.
How Flutter and Rust stay synchronized
The main risk of a cross-language architecture is that both sides work well independently but drift when combined.
I primarily use snapshots and revisions to prevent that drift.
A snapshot is the complete state confirmed by the Core at a particular moment. Its revision is the version number. Flutter accepts a snapshot only when it is newer than the one already displayed. If an event is missed, a listener starts late, or the revision jumps, Flutter asks the Core for a fresh complete snapshot instead of guessing what happened in between.
Rust: revision 40 → 41 → 42
Flutter receives: revision 40 → 42
↑
gap detected; reload full state
An event answers “what just happened?” A snapshot answers “what is true now?” They serve different purposes.
Core instances must not follow page rebuilds.
Database connections, playback state, and background jobs are usually app-scoped objects held by a repository or runtime. Rebuilding a widget must not reopen the Rust Core, and a page does not need to see the internal handle.
Cancellation must enter the Core.
Flutter dropping a Future does not mean a Rust scan has stopped. Long operations need explicit cancellation, and a cancelled result must not commit itself later.
Errors must remain meaningful to the UI.
Authentication failure, timeout, network unavailability, corrupt data, and an incompatible service are different user problems. Rust returns a stable error category. Flutter then decides whether to show login, retry, or configuration actions instead of parsing an error string.
High-frequency data cannot cross without limits.
Playback and scan progress can update rapidly. Those values need throttling, merging, or bounded buffering. Important state must also remain available through a fresh snapshot.
All of these mechanisms solve the same problem:
Flutter may temporarily lag behind. It must not own a different answer.
I did not rewrite the old project all at once
Once the target architecture is visible, the most dangerous impulse is to translate every Dart business module into Rust in one pass.
I did not take that route.
First migrate one complete path.
The actual migration moved one vertical business path at a time. I selected a well-bounded capability, carried it from a screen action through FRB into Rust, and returned the complete result to Flutter.
One screen action
→ one Flutter Repository
→ one FRB contract
→ one Rust use case
→ one real data or engine adapter
→ one renderable result
Only after that path became stable did I migrate the next one.
Then change the owner of the state.
The old and new implementations may coexist temporarily, but every important state needs a planned ownership cutover. After Rust takes ownership, legacy code may continue to serve capabilities not yet migrated. It must not keep mutating data already assigned to the Core.
Finally remove the old entry point.
I now judge whether a migration phase is complete by whether the previous state owner can be deleted, not by how many new directories were added.
If the new architecture only adds wrappers while the old controller still owns the real state, the call graph has merely grown. The boundary becomes real only after the old writer, queue, and business branch leave that path.
Rust Core stability does not come from the word “Rust”
Rust provides memory safety, a strong type system, and useful concurrency tools. It does not design correct business behavior for me.
The practices I value most inside the Core are fairly ordinary:
| Practice | The problem it prevents |
|---|---|
| Use distinct types for IDs with different meanings | Mixing source IDs, remote IDs, and global IDs |
| Give the database one writer | Asynchronous paths overwriting one another |
| Commit complete operations in transactions | UI observing half-old, half-new data |
| Let only the actor change playback state | Taps, system callbacks, and recovery fighting for state |
| Give long operations timeouts and cancellation | Work continuing forever after a screen is gone |
| Return stable error categories | Flutter parsing error text that may change |
Logging is also part of stability. The Core records operation IDs, source IDs, state changes, and committed revisions. It does not log passwords, authorization headers, tokens, or signed playback URLs.
The external FRB contract stays as stable as possible. Rust can replace the database, network library, or audio implementation without forcing Flutter to change, as long as the meaning of the commands and results remains intact.
Stability does not mean the internals never change. It means an internal change does not shake every consumer outside.
The build has one more layer of cost
This architecture is not free.
The project now builds Rust through Native Assets. A Flutter build invokes Cargo and packages the resulting native library as part of the app. FRB also provides an official Native Assets integration guide.
This removes some manual FFI and platform packaging work, but it does not eliminate the native toolchain. iOS devices and simulators, Android ABIs, the NDK, Rust targets, minimum OS versions, and C/C++ dependencies such as FFmpeg still require separate attention.
FRB itself also needs version management. The Rust crate, Dart runtime, code generator, and build hook should stay aligned. An upgrade should regenerate bindings and rerun Rust, Flutter, and platform tests.
This is why I would not recommend adding Rust to every Flutter project immediately.
| What the architecture gains | What it costs |
|---|---|
| A harder business boundary | Another language and cross-language debugging |
| A Core that runs independently | Cargo and a native build toolchain |
| Clearer state ownership | Careful bridge-contract design |
| Reuse from a CLI or another host | Maintaining native binaries across platforms |
For an app made mostly of forms and ordinary API calls, clear Dart layering is usually enough. Another language, another build system, and cross-language debugging may not be worth the cost.
A Rust Core becomes worth considering when several of these conditions are present:
- complex, long-lived business state;
- substantial file, media, index, or local-data processing;
- several asynchronous entry points competing over the same state;
- a need to test the Core independently of Flutter;
- a possible CLI, desktop client, or another future host;
- a team willing to maintain Rust and the native build chain.
The deciding factor is not the number of screens. It is whether the application contains a stable core that is valuable enough to stand on its own.
If I were modernizing another old project
After this experience, I would begin by drawing the state flow, not by installing FRB.
I would locate the state that is easiest to lose control of and count how many modules can currently mutate it. I would select one owner, define one complete command and result, and then connect the smallest vertical path across Flutter, FRB, and Rust.
I would be especially careful not to:
- translate the old controller into Rust line for line;
- let Flutter and Rust keep writing the same state indefinitely;
- let widgets call generated APIs directly;
- create a large collection of tiny FFI getters;
- mistake a returned
Futurefor a committed business operation; - rely only on Rust unit tests while ignoring device lifecycle behavior;
- keep the old authority forever “just in case.”
These lessons are not limited to music apps.
Download managers, document processors, image and video tools, offline databases, synchronization engines, encryption tools, and complex device protocols can all use the same idea: Flutter keeps control of interaction and platform experience, while a Rust Core preserves the business capabilities that need long-term stability.
Looking back
I originally expected Flutter plus Rust to be mostly about FFI, performance, and native builds.
In practice, the most valuable change was clearer responsibility.
Flutter no longer needs to understand how the database commits a synchronization, and it no longer maintains another copy of the player’s internal state. It expresses user intent and renders results already confirmed by the Core.
The Rust Core does not care about button colors or navigation. It ensures that a command occurs in a deterministic order, that failures have clear meaning, and that recoverable state is actually committed.
FRB sits in the middle. It makes no business decision for either side; it only lets both sides speak through the same contract.
Flutter owns the experience
FRB owns the connection
Rust Core owns the facts
That is the most direct way I currently understand this architecture.
It does not remove complexity, and it does not prevent every failure. It keeps complexity from flowing everywhere: interface complexity remains in Flutter, core complexity remains in Rust, and the two cooperate through a small set of clear, stable operations.
For a Flutter project that has started to feel heavy, that boundary may be more valuable than simply switching to a faster language.