By Nabin Ghimire, Flutter Developer & Computer Engineer in Kathmandu, Nepal
Published
Updated
9 min read
Every Flutter clean architecture article I read early on spent its word count on folder names. features/ or modules/? data/ inside the feature or beside it? None of that decides whether a codebase is readable in eighteen months. What decides it is which direction the dependencies point, and whether the person who joins after you can add a screen without editing four unrelated files. I have built the AalaDoc healthcare app and several smaller Flutter projects on the version below. It is not the only correct structure; it is the one I can defend line by line, including the parts that cost more than they save.
Three layers, and only three
The split I use per feature:
presentationholds widgets, screens and the state objects the UI listens to. It knows about the domain, never about a network client.domainholds entities and the repository contract, expressed as an abstract class. It depends on nothing outside Dart.dataholds the models, the remote and local data sources, and the concrete repository. It knows about the domain contract and the outside world.
Dependencies point inward: presentation to domain, data to domain. Domain depends on nothing. That is the whole rule, and the only one worth enforcing with a linter, because every other correct choice follows from it. In practice it means a widget cannot import an HTTP client, and a change to an API response shape cannot reach a screen.
The repository is a contract, not a wrapper
The most common mistake I see is a repository that forwards every call straight to an API client. That is a wrapper, and it buys nothing: the UI still talks in HTTP terms, so the transport leaks anyway. A repository should speak the language of the feature.
abstract class AppointmentRepository {
Future<List<Appointment>> upcoming(String patientId);
Future<void> book(NewAppointment draft);
}Note what is missing: no Response, no status codes, no JSON. The implementation decides whether an answer comes from a REST call, a Firebase stream or a cached box on disk. Swapping any of those is a change in one file, and the fake used by tests is about thirty lines.
Keep state boring
State management debates are mostly about taste. The rule I care about is narrower: state should be boring enough that a new screen is written by copying the shape of an old one. Whether that is Bloc, Riverpod or a ChangeNotifier matters far less than whether there is one pattern and it is followed everywhere. In the AalaDoc build I used a cubit per screen for anything with a request lifecycle: loading, loaded and error, plus the small set of actions the screen exposes. Screens that only render data get no state object at all. A second pattern is how one codebase quietly becomes two.
What I refuse to put in a widget
Widgets build UI. That is the entire job. Three things never belong inside one:
- A network call. The widget asks the state object, the state object asks the repository.
- Date, currency or unit formatting. It is testable, so it belongs somewhere tests can reach.
- Business rules. "Can this patient reschedule?" is a domain question, not a
boolcomputed inside a builder.
Keeping those out is what makes widgets short enough to read in one pass, and it is the difference between a screen you can fix in ten minutes and one you have to run in order to understand.
Test the seams, not the screens
Full widget-test coverage on a client project is rarely worth its cost, and buying it usually means nobody writes tests at all. What is worth the effort: tests at the seams. A repository contract with a fake implementation catches most integration mistakes, and pure functions for the parsing and validation rules catch the bugs that actually reach production. The practical effect is that when something breaks, there are only a few places it can be breaking, because the layers do not leak into each other. Debugging becomes reading two files instead of chasing state across a widget tree.
What this costs
Honesty first: this structure adds files. A small screen that would be one file becomes four. For a prototype or an internal tool with a two week life, that is a bad trade and I skip it entirely. The payoff arrives at the second developer and the second release. On AalaDoc, adding a screen after launch meant a model, a repository method and a screen, with no risk of breaking the booking flow. That is the trade: files now, isolation later.
Where this pattern gets in the way
It is not free, and pretending otherwise is how architecture advice loses credibility. Three places it actively hurts:
A screen with no business rules does not need a domain layer. If a settings page reads one flag and writes one flag, four files is theatre. I keep those as a single widget plus a thin service call and move on. A one person prototype should not pay for repository abstractions it will never swap. The moment to introduce the split is when a second person joins or a second data source appears, not on day one. Over-applied use cases turn every tap into three files of ceremony. A use case earns its existence when the rule inside it is reused or tested in isolation; otherwise it is a wrapper with a longer name.
The way I decide is with a single question: will this boundary save more time than it costs to maintain? For the booking flow in a healthcare app, yes. For a theme toggle, no. Everyone draws that line somewhere; the mistake is drawing it in a different place for every feature.
Migrating an existing app without a rewrite
You rarely get to start clean, and a full rewrite to adopt this structure is a bad trade. The migration I have used goes in this order, and each step is shippable on its own:
- Introduce the repository contract for the noisiest feature first, and implement it over the network code you already have.
- Move the models behind that contract so the UI stops importing them. This is where most of the value lands.
- Split the state object out of the widget tree for the screens that were doing the most in their build methods.
- Repeat per feature as you touch it. Do not run a section by section refactor; nothing else gets shipped during one.
Rules that survived contact with a real project
- One repository contract per feature, not one per endpoint.
- Models never leave the data layer; domain entities are what the UI sees.
- Errors are values. Map them to a domain failure type at the repository boundary so the UI never has to catch a raw exception.
- If a widget needs two repositories, that is usually a missing use case rather than permission to pass both in.
- Name things after the domain.
AppointmentRepositoryages better thanBookingApiService.
None of this is novel, and that is the point. It is simply the version that stayed true after six months of feature work, which is the only test of an architecture that counts. If you are starting a Flutter app today, pick these three layers, write one repository contract, and refuse to let HTTP types past the boundary. Everything else you will adjust as you go.
