← Back to Blog

Flutter Vibe Code with Clean Architecture: The Complete Guide

December 21, 202414 Minutes Read
flutterclean architecturevibe codingmobile developmentdartsoftware architectureflutter architecturemvvmrepository patterndependency injectionseparation of concernscode organizationbest practicesscalable appsmaintainable code

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:

  1. Domain Layer (innermost): Business logic, entities, use cases
  2. Data Layer: Repositories, data sources, models
  3. 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

  1. Feature-First Organization: Organize by feature, not by layer
  2. Dependency Rule: Dependencies point inward (presentation → domain ← data)
  3. Separation of Concerns: Each layer has one responsibility
  4. 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_it or injectable: Dependency injection
  • equatable: Value equality for entities
  • freezed: Immutable classes and unions
  • bloc or riverpod: State management
  • mockito or mocktail: 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:

  1. Pick one feature
  2. Organize it into domain/data/presentation
  3. Use interfaces for abstractions
  4. Add tests
  5. 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.