Feature-First MVVM + Repository: A Stress-free Flutter Architecture (Riverpod v3 Edition)

1. Let's talk about the mess first
Okay, be honest with yourself for a second. You started a Flutter app. It was small. You had a screens/ folder, a widgets/ folder, maybe a models/ folder if you were feeling fancy. Everything lived directly inside your StatefulWidget. API calls happened in init initState. Loading spinners were a bool isLoading sitting next to your button color logic. Life was good.
Then the app grew.
Now home_screen.dart is 900 lines long. It fetches data, parses JSON, handles pagination, shows snackbars, formats currency, and also happens to build some UI somewhere in there if you scroll far enough. You want to write a unit test for "what happens when the API call fails" and you realize... you can't, because that logic is welded to a BuildContext and a widget lifecycle.
You add a new teammate. They ask "where do I put the code that fetches the user's bookings?" and there's an awkward silence because the honest answer is "wherever felt right at 11pm three months ago."
This is the problem almost every growing Flutter app hits: UI code, business logic, and data-fetching code all tangled together, with no consistent place for anything.
The fix isn't some exotic enterprise pattern. It's something refreshingly simple: separate what things do, and organize by feature instead of by file type. That's it. That's the whole idea behind Feature-First MVVM + Repository, and paired with Riverpod v3, it becomes genuinely pleasant to work with.
2. The solution, at a glance
Instead of organizing by type, where every screen, every widget, and every model sits in one big shared bucket regardless of what feature it belongs to, you organize by feature first. Each feature — Login, Home, Booking, whatever — gets its own folder. Inside that folder, you split things by role: one place for plain data, one place for the UI, and one place for the logic and networking.
Shared things that every feature needs, like your network client, your app-wide theme, your router, and your common widgets, live in a separate shared core area that no single feature owns.
The result: open any feature folder, and you immediately know where the data models are, where the screens are, and where the logic lives. Nothing is scattered across the codebase, and nothing forces you to jump between five unrelated folders just to make one change.
Within each feature, you follow the classic MVVM split:
The Model is just data. No logic, no widgets, no API calls.
The View is just UI. It watches state and shows it, and forwards user taps upward.
The ViewModel is the brain. It holds state, talks to the Repository, and decides what the View should see.
And one addition on top of classic MVVM: the Repository, which is the only piece allowed to know that an API, or a database, or a cache even exists.
3. The theory: why does it look like this?
Let's talk about why, not just what.
The core idea is one-way responsibility and one-way data flow. The View sends user actions up to the ViewModel. The ViewModel calls the Repository. The Repository talks to the outside world, like an HTTP API. Data flows back down the same chain in reverse: the Repository hands data to the ViewModel, and the ViewModel updates state that the View is watching.
Each layer only talks to its direct neighbor. The View never touches the networking library directly. The Repository never knows what a widget is. The ViewModel never builds UI. This isn't a rule for the sake of rules. It exists because of what it buys you:
If your Model is just data, it's trivially testable and easy to serialize, with no side effects to worry about.
If your View is dumb, meaning it just reads state and calls functions, you can redesign the entire UI without touching a single line of business logic.
If your ViewModel doesn't know about the networking layer directly, you can test things like "does tapping submit twice cause a duplicate network call" without spinning up any real network stack.
If your Repository is the only door to the outside world, swapping your backend, adding caching, or faking data for tests becomes a change in one file instead of a hunt-and-replace across twenty widgets.
As for why feature-first instead of type-first: the alternative, with top-level folders for models, views, and viewmodels, sounds tidy until your app has fifteen features. Then every folder has fifteen unrelated files sitting side by side, and touching "the booking flow" means jumping between several distant folders for every single change. Feature-first fixes this by keeping everything about one feature together, while the shared core area holds only the things that truly have nothing to do with any single feature. This mirrors how you actually think about your own app. You think "I'm working on booking," not "I'm working on the viewmodels layer."
4. Strengths, weaknesses, and Riverpod's not-so-secret second job
Strengths first. You always know where things go: a new feature means a new folder with the same three sub-parts, no debates needed. It's testable, since ViewModels and Repositories are plain classes and functions with no widget tree required to test them. It's parallel-friendly, since two people can work on two different features without touching the same files. It keeps your UI swappable, since dumb Views rarely need to change when business logic changes. And Riverpod does double duty here, giving you state management and dependency injection at the same time, without needing a separate dependency injection package.
Now the honest weaknesses. There's real boilerplate: a state class, a way to update that state immutably, and a controller, just for a screen that shows three text fields, is a lot of ceremony for something simple. There's no true domain layer the way stricter architectures have, so business rules can quietly leak into ViewModels if you're not disciplined about keeping them out. The line between Repository and ViewModel can blur over time, since it's tempting to put "just one more bit of logic" into the Repository until it's doing more than just fetching data. And this approach is overkill for tiny apps; a single-screen utility app doesn't need this much structure. It really shines once you're past a handful of screens with multiple contributors.
On Riverpod specifically: this is the part people underrate. In a lot of writeups about this architecture, dependency injection is treated as a separate concern you bolt on with another package. With Riverpod, you don't need to, because providers already act as your dependency container. A ViewModel never constructs its own Repository. It asks Riverpod for one. That is dependency injection: the dependency is provided to the consumer, not created by it. And because it's the same mechanism as your state management, you get several things for free. Providers are lazy by default, so nothing is created until something actually asks for it. They're cached automatically, so asking for the same provider twice gives you the same instance, which is exactly what you want for something like a Repository that should behave like a singleton. They're easy to override in tests, so a ViewModel can be tested against a fake Repository with no separate mocking framework needed. And they support scoped watching, so a widget can rebuild only when the one specific field it cares about changes, instead of the whole state object.
So if someone asks where your dependency injection setup is, the honest answer is that it's the same providers already doing your state management. One mechanism, two jobs.
5. Step-by-step: building a feature from scratch
Here's the recipe you repeat for every new feature.
Step one: create the feature's folder skeleton, with three sub-folders for data, UI, and logic.
Step two: define the Model. This is just a plain data holder with the fields you need, plus a way to build it from the raw data your API returns. Nothing else belongs here.
Step three: define the Repository. This is the only place allowed to know an API exists. It exposes methods like "get the menu" or "place an order," and internally it's the one making the actual network call, parsing the raw response, and turning it into your Model objects. If something goes wrong, like a timeout or a bad response, the Repository is also the one responsible for turning that into a clear, typed failure instead of letting a raw, confusing error bubble up.
Step four: define the ViewModel. This holds a state object describing everything the screen needs to know, like whether it's loading, whether there was an error, and what data it has. The ViewModel exposes actions, like "load the menu," which call the Repository and then update the state based on what came back, success or failure.
Step five: define the View. This watches the ViewModel's state and renders accordingly: a loading indicator while loading, an error message if something failed, or the actual content once it's ready. Every user interaction, like tapping a button, gets forwarded straight to the ViewModel. The View itself makes no decisions.
Step six: wire the feature into your app's navigation, and you're done. That's the entire loop, and it stays identical for every feature you add afterward.
6. The pizza shop, humanized
Let's make this stick with a story instead of just structure.
Imagine your app is a pizza shop, and four roles work the counter.
The order slip, playing the role of the Model, is just a piece of paper: pizza name, price, a picture. It doesn't do anything. It doesn't know how to cook, and it doesn't know how to talk to customers. It's just information sitting on a clipboard.
The delivery guy, playing the role of the Repository, is the only person allowed to leave the building. If you need pizza data, he's the one who calls the supplier, waits for the delivery, and hands back a box. He doesn't care who asked for the pizza or what they'll do with it. His whole job starts and ends at "go get pizza, bring pizza back." If the supplier is closed or the truck breaks down, he's also the one who says "sorry, no pizza today," in plain language, instead of making everyone else decode a cryptic error from the supplier's paperwork.
The shop manager, playing the role of the ViewModel, never talks to the supplier directly. That's not their job. Instead, they tell the delivery guy to go get today's menu. While waiting, the manager tells the front counter to put up the "loading" sign. When the delivery guy comes back, whether he succeeded or not, the manager decides what the front counter should show: either "great, here's the menu," or "sorry folks, we're having supply issues, let them know." Notice the manager doesn't personally know how the pizza was fetched. That detail doesn't matter to them. They just know to ask the delivery guy, then update the sign based on the answer.
The menu board, playing the role of the View, is dumb on purpose. It doesn't know why the loading sign is up, and it doesn't know what went wrong on the supplier's end. It just displays whatever the manager tells it to display. If someone taps "refresh," the board doesn't refresh itself. It just tells the manager that a tap happened, and waits to be told what to show next.
That's the whole architecture. Four roles, one direction of information flow, nobody stepping on anybody else's job.
7. Where this goes from here
Once your pizza shop stops being a single stand and starts being a chain, the same four roles keep working, you just lean on them more deliberately. Feature folders start holding a few more logic files instead of one big one. Failures get modeled explicitly as clear, typed categories, like "no connection" or "invalid input," instead of raw, generic errors, so every screen can react to them the same predictable way. Because the ViewModel never talks to the network directly, it becomes easy to test its behavior in isolation by swapping in a fake Repository. And because the Repository is the single door to the outside world, things like caching or pagination become a change inside one class, invisible to everything built on top of it.
None of that requires changing the shape of the architecture. It's the same four roles, doing a bit more work, in the same well-defined places.
Wrapping up
Feature-first MVVM plus Repository isn't flashy. It won't get you invited to give a conference talk about elaborate layered architectures. But it hits a sweet spot: simple enough to explain to a new teammate in five minutes, structured enough that a large app doesn't collapse under its own weight, and with Riverpod v3 handling state management and dependency injection in one breath, genuinely low-ceremony to actually build with day to day.
Start with the pizza shop. Add a feature. Add another. You'll notice the pattern never has to change, you just repeat it.