After fifteen production apps, the patterns that survive are unglamorous: dependencies point inward, features are self-contained, and tests are a habit.
Why clean architecture survives contact with production
Every Flutter project starts clean. Somewhere around the fifth feature request, the widgets start talking directly to repositories, the repositories start talking to three different services, and the state starts living wherever it was created. Clean architecture does not prevent that drift — it makes the drift expensive enough that it stops happening.
The version we use in production separates the app into three layers: presentation (widgets and BLoCs), domain (use cases and entities), and data (repositories, DTOs, and remote/local sources). The golden rule: dependencies point inward. Presentation knows about domain. Data implements domain interfaces. Domain knows nothing about Flutter widgets, HTTP clients, or databases. When that rule is enforced, swapping a backend or rewriting the UI becomes a contained change instead of a ground-up rewrite.
The folder structure we actually ship
lib/
core/ # themes, routing, shared widgets, network client
features/
chat/
domain/ # entities, use cases, repository interfaces
data/ # repository impl, DTOs, remote + local sources
presentation/ # blocs, pages, widgets
We organize by feature, not by layer. A feature folder that is three levels deep tells you everything: what this part of the app does, where its data comes from, and how it is shown. Browsing features/chat answers almost every "where is X?" question in seconds, even for engineers who joined the project last week.
State management: BLoC with boundaries
BLoC is the ecosystem default for a reason: it forces events in and state out, which makes the state machine testable without rendering a single widget. Our convention is one bloc per feature or per screen group, with sealed events and immutable state classes. A bloc never talks to a repository implementation directly — it depends on an interface from the domain layer, which the composition root provides. That single rule is what makes widget tests fast and unit tests meaningful.
Repositories: the one interface that matters
Your repository interface is the contract that absorbs reality. "Send a message" might today hit a WebSocket, tomorrow a REST queue, and next quarter a database replica — the screen should not be able to tell the difference. We keep repository interfaces thin and synchronous-looking (futures or streams), and push all the mess of retries, caching, and error mapping into the data layer.
// domain/repositories/chat_repository.dart
abstract class ChatRepository {
Stream<Message> messages(String roomId);
Future<void> send(OutgoingMessage message);
}
Because the domain layer defines the interface, the presentation layers compile against an abstraction, and the mock for tests is a ten-line class instead of a mocking-framework ceremony.
Dependency injection without the ceremony
We use injectable for code generation and get_it as the service locator at runtime. The important part is not the package — it is the composition root. One init() in main.dart wires the entire graph: which repository implementation ships, which base URL the network client uses, which cache backend is enabled. Feature teams register their dependencies at their own modules, so adding a feature never touches someone else's wiring.
@injectable
class ChatBloc extends Bloc<ChatEvent, ChatState> {
final ChatRepository _chat;
ChatBloc(@Named('remote') this._chat) : super(const ChatState.initial());
}
The moment you can construct any bloc with a fake repository in two lines, you will actually write the tests — which is the entire point.
Testing: the habit, not the tool
The mistake teams make is treating tests as a delivery milestone instead of a habit. Our working agreement is small and boring:
- Every bloc has unit tests for its happy path, error path, and loading state.
- Repository implementations are tested against a fake server or a dockerized dependency.
- Widget tests cover the screens that have real business rules; the rest get smoke-tested by CI.
- Golden tests are kept deliberately rare — two or three per app — because they turn every design tweak into work.
None of this is glamorous. It is what makes a 15-app Flutter practice possible. The apps we maintain still ship after years because a junior engineer can change a repository and the suite tells them in ninety seconds whether they broke the domain.
What we stopped doing
- Global singletons for state. They are testable until they are not, and they are never testable in parallel.
- Passing repositories through constructors of constructors. That is what the composition root is for.
- Putting business rules in widgets. If a widget computes a price or a permission, it is in the wrong layer.
- Rewriting "temporary" services. If it talks to a server, it gets an interface from day one.
Local persistence and offline-first
Most mobile products eventually need offline-first behaviour, and clean architecture makes it boring. The repository interface stays the same; the data layer decides whether the source of truth is the server or a local SQLite database. Screens read from the same repository they always did, so the offline logic — sync queues, conflict resolution, merge windows — lives entirely inside the data layer where it belongs. When a screen cannot tell whether it is talking to a server or a phone, you have designed the seam correctly.
The code-review checklist
A code review that checks one question — "does this pull request keep dependencies pointing inward?" — catches most architecture decay before it starts. We review for four things only: whether a bloc touches a widget, whether a repository depends on something above it in the layer stack, whether a new service got an interface, and whether the change still boots from the composition root. Everything else is style, and style disputes are better settled by a formatter than by seniority.
Performance budgeting as architecture
The clean structure only matters if the app also feels fast, so we treat performance as a first-class constraint from the design phase. Every screen defines a budget: frame build time, initial render time, and the size of the widgets subtree. Hot reload makes performance debt invisible — a nine-hundred-widget page still builds, it just crawls on a mid-range Android. We catch it with a simple practice: every release runs on a low-end device in a smoke test, and any screen that drops below the budget gets a redesign ticket before it ships.
The same discipline applies offline. Syncing thousands of rows on a weak connection is a UX problem, not just a data problem. Our offline repositories batch writes, respect the platform's background limits, and expose sync progress as state — so the UI can say "12 messages waiting" instead of silently failing. All of it stays in the data layer, behind the same repository interface the online path uses.
Conclusion
Clean architecture in Flutter is not a diagram, it is a cost structure. When dependencies point inward and features are self-contained, adding engineers, swapping backends, and writing tests all get cheaper at once. We use this exact structure in the live streaming apps and Flutter products we ship from Dhaka — and it is the same discipline we apply to everything else we build.