Clean Architecture in Flutter, One Level Deeper
Stop explaining layers like a tutorial. Learn to justify every boundary you draw and prove what breaks when you violate them.
01. The Real Question Interviewers Are Asking
If you're interviewing for an intermediate or senior Flutter role, you will almost certainly be asked about Clean Architecture. But here is the trap: most developers answer by reciting a tutorial.
They will proudly list the three layers—Presentation, Domain, and Data—and explain that UI goes in Presentation, APIs go in Data, and Business Logic goes in Domain. To a senior engineering manager, this answer is a red flag. It shows you've read about the architecture, but it doesn't prove you've suffered the consequences of implementing it poorly in production.
At the senior level, "Have you used Clean Architecture?" is not the real question. The real question is: "Can you justify EVERY layer boundary you drew, and tell me exactly what breaks when someone on your team violates it?"
This post maps out exactly how to elevate your answer from a textbook definition to a production-hardened justification. We'll use a consistent example throughout: a Document Verification (KYC) flow, where users upload an ID and the system validates their status.
Widgets, BLoC/Cubit
Entities, Use Cases, Repository Interfaces
APIs, Local DB, Repositories
02. Why Use Cases Own the Logic (And BLoC Doesn't)
Let's do the lightning-fast recap: Presentation is how things look and how state is held for the UI. Data is where things are stored or fetched from. Domain is the pure Dart rules of your business.
The boundary interviewers probe most heavily is the line between Presentation and Domain. Specifically: Why do we need Use Cases? Why not just put the business logic inside the BLoC or ViewModel?
Imagine our Document Verification flow. The user taps "Submit." Here is the logic that must execute:
- Check if the selected file size is < 5MB.
- Check the user's current KYC status locally (are they already verified?).
- Call the upload API.
- Log an analytics event on success.
Putting those four steps directly into `on
What breaks when you do this? Two critical things a senior dev immediately recognizes:
1. Testing becomes a nightmare. To test that the 5MB file limit works, you cannot just test the logic. You have to instantiate the `DocumentBloc`, mock its initial state, add a stream listener, trigger the `SubmitDocumentEvent`, wait for the event loop to yield, and assert the emitted state. You are testing the Presentation layer machinery just to verify a math equation.
2. Logic duplication across UI surfaces. Three months later, product asks you to build an internal Flutter Web admin dashboard where admins can manually upload documents for users. The admin dashboard uses a completely different UI and a different state management approach. Because your logic is trapped inside `DocumentBloc`, you end up copy-pasting the 5MB check and analytics logging into the new web screen. The rules drift out of sync.
When you wrap those four steps in a `VerifyDocumentUseCase` inside the Domain layer, the BLoC's only job becomes: "Call the Use Case, if it returns Success, yield SuccessState; if it returns Failure, yield ErrorState." The business logic becomes universally testable and completely divorced from Flutter itself.
03. Module-Level Dependency Inversion
This is the concept that separates candidates who've watched a YouTube tutorial from those who have maintained a multi-year codebase.
Dependency Inversion dictates that high-level modules (Domain) should not depend on low-level modules (Data). Both should depend on abstractions (Interfaces). In Flutter, developers often think they've achieved this by creating an abstract `IDocumentRepository`.
But if your domain file still has `import '../data/models/api_response.dart';` at the top, you have failed to invert the dependency.
import '../../data/models/doc_dto.dart';
import '../repositories/i_doc_repo.dart';
class VerifyUseCase {
final IDocRepo repo;
...
}
name: domain_layer
dependencies:
dartz: ^0.10.1
# ZERO dependency on data layer
# Nothing can leak, compiler prevents it.
In a real senior interview, you need to explain how you physically enforce this. It's not enough to say "we try not to import data in domain." You say: "We separate the layers into local Melos packages in a monorepo. The `domain` package's `pubspec.yaml` simply does not include the `data` package. If a junior developer tries to import a data model into an entity, the Dart compiler throws an error."
If you don't use multi-package workspaces, mention using static analysis tools like `import_lint` or custom `dart_code_metrics` (now DCM) rules that fail your CI pipeline if `lib/domain/**` imports `lib/data/**`.
04. Isolating Domain: The Document Verification Flow
Let's make this abstraction concrete. How do we ensure the Domain layer remains completely ignorant of whether we use Firebase, a REST API, or an on-device ML model to verify the document?
The Domain layer dictates the contract. It creates an interface tailored exactly to what the application needs, speaking strictly in Domain Entities, never in DTOs (Data Transfer Objects) or JSON.
// 1. DOMAIN LAYER (Defines the contract)
// Has no idea how the document is uploaded.
abstract class DocumentRepository {
Future<Either<Failure, DocumentEntity>> uploadForVerification({
required File imageFile,
required String userId,
});
}
// 2. DATA LAYER (Implements the contract)
// Depends on the Domain layer to get the interface.
class ApiDocumentRepository implements DocumentRepository {
final DocumentRemoteDataSource remoteDataSource;
ApiDocumentRepository(this.remoteDataSource);
@override
Future<Either<Failure, DocumentEntity>> uploadForVerification({
required File imageFile,
required String userId,
}) async {
try {
// Data layer handles the dirty work: multipart forms, DTOs, JSON
final DocumentModel dto = await remoteDataSource.uploadMultipart(imageFile, userId);
// Map the Data Model (DTO) back to a pure Domain Entity
return Right(dto.toEntity());
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
}
05. Honest Take: When Clean Architecture is Overkill
A junior developer insists their architecture is perfect for every situation. A senior developer knows the cost of their architecture and when to abandon it.
Clean Architecture is expensive. It requires mapping models to entities, creating interfaces for every interaction, and navigating through multiple files just to trace a simple API call. If you are building a short-lived MVP to test market fit, or a simple internal CRUD app with no complex offline-first requirements, do not use it.
A senior move in an interview is saying: "On my last project, we used Clean Architecture for the core transaction flows, but for a simple 'App Settings' screen that just saved 3 booleans to SharedPreferences, we let the ViewModel talk directly to a service class. The boilerplate wasn't worth the abstraction." Pragmatism always beats dogma.
06. Interview Prep Worksheet
Don't go into the interview with generic examples. Map the concepts from this article directly to the codebase you are working in today. Mentally fill out these cards before your next interview:
Expect these pushbacks when discussing architecture:
- "Why not just have the UI call the Repository directly if the Use Case only has one line of code?"
Answer: "Pass-through use cases are annoying, yes. But they establish a standard. If we allow UI to call repos directly for 'simple' things, developers will disagree on when something becomes 'complex' enough to need a Use Case. Consistency prevents architectural decay." - "What actually breaks if business logic lives in the BLoC?"
Answer: "Testability and portability. You can no longer unit test the business rule without mocking stream subscriptions and initial UI states. You also can't reuse that logic if you add a different UI surface, like a background sync worker or a web dashboard."
07. Senior-Level Sample Answer
When asked to describe how you implement Clean Architecture, do not give a textbook definition. Give a business-value justification. Use this template, adapting the bracketed sections to your own real-world experience:
By strictly separating our packages, our Domain layer literally cannot import Flutter material libraries or API data models. This paid off heavily when we [business event, e.g., had to adapt the mobile logic for an internal web dashboard / swapped our backend provider]—we could reuse the exact same core logic without touching a single line of UI code, and our unit tests proved it still worked."
Before moving to the next post, answer this out loud: If you have a `UserEntity` in the Domain layer and a `UserModel` in the Data layer, where does the extension/function that converts `UserModel` to `UserEntity` live?
(Answer: In the Data layer. The Domain layer must not know `UserModel` exists.)
Official Resources
To see how the Flutter team frames these boundary discussions, refer to the official docs:

Comments
Post a Comment