Flutter Vibe Code with Clean Architecture: The Complete Guide
Flutter Vibe Code with Clean Architecture: The Complete Guide
You love the freedom of vibe coding—building features intuitively, following your instincts, and moving fast. But you also know that without proper architecture, your Flutter app will become a mess of tangled code that's impossible to maintain.
The good news? You don't have to choose between vibe coding and clean architecture. In fact, Clean Architecture makes vibe coding more sustainable by giving you a structure that supports rapid iteration while keeping your code maintainable.
In this comprehensive guide, we'll explore how to combine the creative freedom of vibe coding with the discipline of Clean Architecture in Flutter. You'll learn practical patterns, project structures, and best practices that let you code fast while building apps that scale.
What is Vibe Coding?
Vibe coding is an intuitive, flow-state approach to programming where you:
- Code by feeling and intuition
- Build features iteratively as ideas come
- Trust your instincts about implementation
- Move fast and adjust as you go
- Prioritize speed and creativity
It's the opposite of over-planning. It's coding in the moment, following your gut, and letting the code guide you.
What is Clean Architecture?
Clean Architecture (popularized by Robert C. Martin) is a software design philosophy that emphasizes:
- Separation of Concerns: Each layer has a single, well-defined responsibility
- Dependency Inversion: Dependencies point inward toward the domain
- Independence: Business logic is independent of frameworks, UI, and databases
- Testability: Each layer can be tested in isolation
- Maintainability: Changes in one layer don't affect others
The Clean Architecture Layers
Clean Architecture organizes code into concentric circles:
- Domain Layer (innermost): Business logic, entities, use cases
- Data Layer: Repositories, data sources, models
- Presentation Layer (outermost): UI, state management, widgets
Why Combine Vibe Coding with Clean Architecture?
At first glance, vibe coding and Clean Architecture seem incompatible:
- Vibe coding = fast, intuitive, unstructured
- Clean Architecture = planned, structured, disciplined
But they're actually complementary:
1. Clean Architecture Provides Structure Without Slowing You Down
A well-organized project structure doesn't slow you down—it speeds you up by:
- Making it obvious where new code belongs
- Reducing decision fatigue ("Where should I put this?")
- Enabling parallel work (team members know where things are)
2. Clean Architecture Makes Refactoring Safe
Vibe coding often leads to code that needs refactoring. Clean Architecture makes refactoring safe:
- Clear boundaries between layers
- Easy to swap implementations
- Changes are localized
3. Clean Architecture Scales with Your App
As your app grows, Clean Architecture prevents it from becoming unmaintainable:
- New features fit naturally into the structure
- Code remains organized as complexity increases
- Team members can contribute without confusion
Flutter Clean Architecture Project Structure
Here's a practical project structure for Flutter apps using Clean Architecture:
lib/
├── core/ # Core utilities and shared code
│ ├── error/
│ ├── usecases/
│ ├── utils/
│ └── constants/
├── features/ # Feature modules (organized by feature)
│ └── authentication/
│ ├── data/
│ │ ├── datasources/
│ │ ├── models/
│ │ └── repositories/
│ ├── domain/
│ │ ├── entities/
│ │ ├── repositories/
│ │ └── usecases/
│ └── presentation/
│ ├── bloc/ # or provider, riverpod, etc.
│ └── pages/
│ └── widgets/
└── main.dart
Key Principles
- Feature-First Organization: Organize by feature, not by layer
- Dependency Rule: Dependencies point inward (presentation → domain ← data)
- Separation of Concerns: Each layer has one responsibility
- Interface-Based: Use interfaces/abstract classes for abstractions
The Three Layers Explained
1. Domain Layer (Business Logic)
The domain layer is the heart of your app. It contains:
- Entities: Pure business objects (no framework dependencies)
- Use Cases: Business logic operations
- Repository Interfaces: Contracts for data operations
// lib/features/authentication/domain/entities/user.dart
class User {
final String id;
final String email;
final String name;
User({
required this.id,
required this.email,
required this.name,
});
}
// lib/features/authentication/domain/repositories/auth_repository.dart
abstract class AuthRepository {
Future<Either<Failure, User>> login(String email, String password);
Future<Either<Failure, User>> register(String email, String password, String name);
Future<Either<Failure, void>> logout();
Future<Option<User>> getCurrentUser();
}
// lib/features/authentication/domain/usecases/login_usecase.dart
class LoginUseCase {
final AuthRepository repository;
LoginUseCase(this.repository);
Future<Either<Failure, User>> call(LoginParams params) async {
return await repository.login(params.email, params.password);
}
}
class LoginParams {
final String email;
final String password;
LoginParams({required this.email, required this.password});
}
2. Data Layer (Data Sources & Repositories)
The data layer handles:
- Data Sources: API calls, local storage, etc.
- Models: Data transfer objects (DTOs)
- Repository Implementations: Concrete implementations of domain interfaces
// lib/features/authentication/data/models/user_model.dart
class UserModel extends User {
UserModel({
required super.id,
required super.email,
required super.name,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'],
email: json['email'],
name: json['name'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'name': name,
};
}
}
// lib/features/authentication/data/datasources/auth_remote_datasource.dart
abstract class AuthRemoteDataSource {
Future<UserModel> login(String email, String password);
Future<UserModel> register(String email, String password, String name);
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final http.Client client;
final String baseUrl;
AuthRemoteDataSourceImpl({
required this.client,
required this.baseUrl,
});
@override
Future<UserModel> login(String email, String password) async {
final response = await client.post(
Uri.parse('$baseUrl/auth/login'),
body: {'email': email, 'password': password},
);
if (response.statusCode == 200) {
return UserModel.fromJson(json.decode(response.body));
} else {
throw ServerException('Login failed');
}
}
// ... other methods
}
// lib/features/authentication/data/repositories/auth_repository_impl.dart
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final AuthLocalDataSource localDataSource;
AuthRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
@override
Future<Either<Failure, User>> login(String email, String password) async {
try {
final userModel = await remoteDataSource.login(email, password);
await localDataSource.cacheUser(userModel);
return Right(userModel);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnknownFailure(e.toString()));
}
}
// ... other methods
}
3. Presentation Layer (UI & State Management)
The presentation layer contains:
- Pages/Screens: UI screens
- Widgets: Reusable UI components
- State Management: BLoC, Provider, Riverpod, etc.
// lib/features/authentication/presentation/bloc/auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final LoginUseCase loginUseCase;
final RegisterUseCase registerUseCase;
final GetCurrentUserUseCase getCurrentUserUseCase;
AuthBloc({
required this.loginUseCase,
required this.registerUseCase,
required this.getCurrentUserUseCase,
}) : super(AuthInitial()) {
on<LoginRequested>(_onLoginRequested);
on<RegisterRequested>(_onRegisterRequested);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
final result = await loginUseCase(
LoginParams(email: event.email, password: event.password),
);
result.fold(
(failure) => emit(AuthError(failure.message)),
(user) => emit(AuthAuthenticated(user)),
);
}
// ... other event handlers
}
// lib/features/authentication/presentation/pages/login_page.dart
class LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<AuthBloc>(),
child: Scaffold(
appBar: AppBar(title: Text('Login')),
body: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is AuthLoading) {
return Center(child: CircularProgressIndicator());
}
if (state is AuthError) {
return Center(child: Text('Error: ${state.message}'));
}
if (state is AuthAuthenticated) {
return Center(child: Text('Welcome, ${state.user.name}!'));
}
return LoginForm();
},
),
),
);
}
}
Vibe Coding with Clean Architecture: Practical Workflow
Here's how to vibe code while maintaining Clean Architecture:
Step 1: Start with the Domain (Think About What, Not How)
When you get an idea, start by defining what you want:
// Vibe: "I want users to be able to like posts"
// Start in domain layer
// 1. Define the entity
class Post {
final String id;
final String title;
final int likeCount;
final bool isLiked;
Post({
required this.id,
required this.title,
required this.likeCount,
required this.isLiked,
});
}
// 2. Define the use case
class LikePostUseCase {
final PostRepository repository;
LikePostUseCase(this.repository);
Future<Either<Failure, Post>> call(String postId) async {
return await repository.likePost(postId);
}
}
Step 2: Implement the Data Layer (Vibe Code the Details)
Now implement the concrete details:
// Vibe code the API call, caching, etc.
class PostRepositoryImpl implements PostRepository {
final PostRemoteDataSource remoteDataSource;
final PostLocalDataSource localDataSource;
@override
Future<Either<Failure, Post>> likePost(String postId) async {
try {
// Try remote first
final post = await remoteDataSource.likePost(postId);
await localDataSource.cachePost(post);
return Right(post);
} catch (e) {
// Fallback to local if offline
final cachedPost = await localDataSource.getPost(postId);
return cachedPost.fold(
() => Left(NetworkFailure('No internet and no cache')),
(post) => Right(post),
);
}
}
}
Step 3: Build the UI (Vibe Code the Experience)
Finally, build the UI:
// Vibe code the UI, animations, etc.
class PostCard extends StatelessWidget {
final Post post;
PostCard({required this.post});
@override
Widget build(BuildContext context) {
return Card(
child: Column(
children: [
Text(post.title),
Row(
children: [
IconButton(
icon: Icon(post.isLiked ? Icons.favorite : Icons.favorite_border),
onPressed: () {
context.read<PostBloc>().add(LikePostRequested(post.id));
},
),
Text('${post.likeCount}'),
],
),
],
),
);
}
}
Dependency Injection in Flutter
Dependency injection is crucial for Clean Architecture. Use get_it or injectable:
// lib/injection_container.dart
final getIt = GetIt.instance;
Future<void> init() async {
// Use cases
getIt.registerLazySingleton(() => LoginUseCase(getIt()));
getIt.registerLazySingleton(() => RegisterUseCase(getIt()));
// Repositories
getIt.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(
remoteDataSource: getIt(),
localDataSource: getIt(),
),
);
// Data sources
getIt.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(client: getIt(), baseUrl: getIt()),
);
// External
getIt.registerLazySingleton(() => http.Client());
getIt.registerLazySingleton(() => 'https://api.example.com');
}
Error Handling with Clean Architecture
Handle errors consistently across layers:
// lib/core/error/failures.dart
abstract class Failure {
final String message;
Failure(this.message);
}
class ServerFailure extends Failure {
ServerFailure(String message) : super(message);
}
class NetworkFailure extends Failure {
NetworkFailure(String message) : super(message);
}
class CacheFailure extends Failure {
CacheFailure(String message) : super(message);
}
// Use Either from dartz package
import 'package:dartz/dartz.dart';
// In repositories
Future<Either<Failure, User>> login(String email, String password) async {
try {
final user = await remoteDataSource.login(email, password);
return Right(user);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnknownFailure(e.toString()));
}
}
Testing with Clean Architecture
Clean Architecture makes testing easy:
// test/features/authentication/domain/usecases/login_usecase_test.dart
void main() {
late LoginUseCase useCase;
late MockAuthRepository mockRepository;
setUp(() {
mockRepository = MockAuthRepository();
useCase = LoginUseCase(mockRepository);
});
test('should get user from repository when login is successful', () async {
// Arrange
final tUser = User(id: '1', email: 'test@test.com', name: 'Test');
when(mockRepository.login(any, any))
.thenAnswer((_) async => Right(tUser));
// Act
final result = await useCase(LoginParams(
email: 'test@test.com',
password: 'password',
));
// Assert
expect(result, Right(tUser));
verify(mockRepository.login('test@test.com', 'password'));
});
}
Common Patterns and Best Practices
1. Use Cases Should Be Simple
Keep use cases focused on one thing:
// ✅ Good: Simple, focused use case
class GetUserProfileUseCase {
final UserRepository repository;
GetUserProfileUseCase(this.repository);
Future<Either<Failure, User>> call(String userId) async {
return await repository.getUser(userId);
}
}
// ❌ Bad: Too much logic in use case
class GetUserProfileUseCase {
// ... too many dependencies, too much logic
}
2. Entities Should Be Pure
Entities should have no dependencies:
// ✅ Good: Pure entity
class User {
final String id;
final String email;
// No dependencies on Flutter, HTTP, etc.
}
// ❌ Bad: Entity with framework dependencies
class User {
final String id;
final JsonEncoder encoder; // ❌ Framework dependency
}
3. Repository Pattern for Data Abstraction
Use repositories to abstract data sources:
// Domain layer defines the interface
abstract class UserRepository {
Future<Either<Failure, User>> getUser(String id);
}
// Data layer implements it
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource remoteDataSource;
final UserLocalDataSource localDataSource;
@override
Future<Either<Failure, User>> getUser(String id) async {
// Implementation details hidden from domain
}
}
4. State Management Integration
Integrate state management cleanly:
// Presentation layer uses use cases
class UserBloc extends Bloc<UserEvent, UserState> {
final GetUserProfileUseCase getUserProfileUseCase;
UserBloc({required this.getUserProfileUseCase}) : super(UserInitial()) {
on<LoadUserProfile>(_onLoadUserProfile);
}
Future<void> _onLoadUserProfile(
LoadUserProfile event,
Emitter<UserState> emit,
) async {
emit(UserLoading());
final result = await getUserProfileUseCase(event.userId);
result.fold(
(failure) => emit(UserError(failure.message)),
(user) => emit(UserLoaded(user)),
);
}
}
Vibe Coding Tips with Clean Architecture
1. Start Small, Scale Gradually
Don't over-engineer from the start:
// Start simple
class GetPostsUseCase {
final PostRepository repository;
GetPostsUseCase(this.repository);
Future<List<Post>> call() => repository.getPosts();
}
// Add complexity as needed
class GetPostsUseCase {
final PostRepository repository;
final CacheManager cacheManager;
GetPostsUseCase(this.repository, this.cacheManager);
Future<Either<Failure, List<Post>>> call() async {
// Add caching, error handling, etc. as you need it
}
}
2. Use Interfaces Liberally
Interfaces make it easy to swap implementations:
// Easy to mock, test, and swap
abstract class PaymentService {
Future<Either<Failure, PaymentResult>> processPayment(Payment payment);
}
// Can swap Stripe, PayPal, etc. easily
class StripePaymentService implements PaymentService { ... }
class PayPalPaymentService implements PaymentService { ... }
3. Keep Layers Independent
Don't let layers depend on each other incorrectly:
// ✅ Good: Domain doesn't depend on data
// domain/repositories/user_repository.dart
abstract class UserRepository {
Future<User> getUser(String id);
}
// ❌ Bad: Domain depends on data
// domain/repositories/user_repository.dart
import '../data/models/user_model.dart'; // ❌ Wrong direction
4. Refactor Fearlessly
With Clean Architecture, refactoring is safe:
// Old implementation
class AuthRepositoryImpl implements AuthRepository {
// ... old code
}
// New implementation (swap easily)
class AuthRepositoryV2Impl implements AuthRepository {
// ... new code, same interface
}
Real-World Example: Building a Feature
Let's build a "Save Post" feature using vibe coding + Clean Architecture:
Step 1: Domain Layer (What We Want)
// domain/entities/saved_post.dart
class SavedPost {
final String id;
final String postId;
final DateTime savedAt;
SavedPost({
required this.id,
required this.postId,
required this.savedAt,
});
}
// domain/repositories/saved_post_repository.dart
abstract class SavedPostRepository {
Future<Either<Failure, SavedPost>> savePost(String postId);
Future<Either<Failure, void>> unsavePost(String postId);
Future<Either<Failure, List<SavedPost>>> getSavedPosts();
}
// domain/usecases/save_post_usecase.dart
class SavePostUseCase {
final SavedPostRepository repository;
SavePostUseCase(this.repository);
Future<Either<Failure, SavedPost>> call(String postId) async {
return await repository.savePost(postId);
}
}
Step 2: Data Layer (How We Do It)
// data/models/saved_post_model.dart
class SavedPostModel extends SavedPost {
SavedPostModel({
required super.id,
required super.postId,
required super.savedAt,
});
factory SavedPostModel.fromJson(Map<String, dynamic> json) {
return SavedPostModel(
id: json['id'],
postId: json['post_id'],
savedAt: DateTime.parse(json['saved_at']),
);
}
}
// data/repositories/saved_post_repository_impl.dart
class SavedPostRepositoryImpl implements SavedPostRepository {
final SavedPostRemoteDataSource remoteDataSource;
final SavedPostLocalDataSource localDataSource;
@override
Future<Either<Failure, SavedPost>> savePost(String postId) async {
try {
final savedPost = await remoteDataSource.savePost(postId);
await localDataSource.cacheSavedPost(savedPost);
return Right(savedPost);
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
// ... other methods
}
Step 3: Presentation Layer (UI)
// presentation/bloc/saved_post_bloc.dart
class SavedPostBloc extends Bloc<SavedPostEvent, SavedPostState> {
final SavePostUseCase savePostUseCase;
final UnsavePostUseCase unsavePostUseCase;
SavedPostBloc({
required this.savePostUseCase,
required this.unsavePostUseCase,
}) : super(SavedPostInitial()) {
on<SavePostRequested>(_onSavePostRequested);
on<UnsavePostRequested>(_onUnsavePostRequested);
}
Future<void> _onSavePostRequested(
SavePostRequested event,
Emitter<SavedPostState> emit,
) async {
final result = await savePostUseCase(event.postId);
result.fold(
(failure) => emit(SavedPostError(failure.message)),
(savedPost) => emit(SavedPostSaved(savedPost)),
);
}
}
// presentation/widgets/save_button.dart
class SaveButton extends StatelessWidget {
final String postId;
final bool isSaved;
SaveButton({required this.postId, required this.isSaved});
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(isSaved ? Icons.bookmark : Icons.bookmark_border),
onPressed: () {
if (isSaved) {
context.read<SavedPostBloc>().add(UnsavePostRequested(postId));
} else {
context.read<SavedPostBloc>().add(SavePostRequested(postId));
}
},
);
}
}
Common Mistakes to Avoid
1. Mixing Layers
Don't let layers bleed into each other:
// ❌ Bad: Domain layer importing from data layer
import '../data/models/user_model.dart';
// ✅ Good: Domain defines its own entities
class User {
// Pure domain entity
}
2. Business Logic in UI
Keep business logic out of widgets:
// ❌ Bad: Business logic in widget
class LoginButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
// ❌ Business logic here
final user = await api.login(email, password);
if (user != null) {
Navigator.push(...);
}
},
child: Text('Login'),
);
}
}
// ✅ Good: Business logic in use case, UI just triggers
class LoginButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
context.read<AuthBloc>().add(LoginRequested(email, password));
},
child: Text('Login'),
);
}
}
3. Over-Engineering
Don't create unnecessary abstractions:
// ❌ Bad: Over-engineered for simple case
abstract class StringValidator {
bool validate(String input);
}
class EmailValidator implements StringValidator {
@override
bool validate(String input) => input.contains('@');
}
// ✅ Good: Simple and sufficient
bool isValidEmail(String email) => email.contains('@');
Tools and Packages
Essential packages for Clean Architecture in Flutter:
dartz: Functional programming (Either, Option)get_itorinjectable: Dependency injectionequatable: Value equality for entitiesfreezed: Immutable classes and unionsblocorriverpod: State managementmockitoormocktail: Mocking for tests
Conclusion: Vibe Code with Structure
Clean Architecture doesn't kill vibe coding—it makes it sustainable. By providing structure and boundaries, Clean Architecture:
- ✅ Gives you clear places to put code
- ✅ Makes refactoring safe and easy
- ✅ Enables fast iteration without technical debt
- ✅ Scales with your app as it grows
- ✅ Makes testing straightforward
Key Takeaways
- Start with domain: Define what you want, not how
- Implement data layer: Vibe code the details
- Build UI: Create the experience
- Use interfaces: Make swapping implementations easy
- Keep layers independent: Domain doesn't depend on data or presentation
- Test each layer: Unit test domain, integration test data, widget test UI
- Refactor fearlessly: Clean Architecture makes changes safe
Start Today
If you're vibe coding without structure, start small:
- Pick one feature
- Organize it into domain/data/presentation
- Use interfaces for abstractions
- Add tests
- Repeat
You'll quickly see how Clean Architecture makes vibe coding faster, safer, and more sustainable.
Remember: Clean Architecture is a tool, not a constraint. Use it to support your vibe coding, not replace it.
Ready to start? Pick a feature and organize it using Clean Architecture. Your future self (and your team) will thank you.