Clean Architecture for AI Agents: 8 Principles for AI-Assisted Flutter Development

Clean Architecture for AI Agents: 8 Principles for AI-Assisted Flutter Development

Uncle Bob's Clean Architecture was designed for humans. The principles assumed a developer would read code sequentially, build mental models across files, and understand implicit relationships through experience. AI agents don't work that way.

This isn't a criticism of Clean Architecture—it remains one of the most valuable frameworks for building maintainable software. But when Cursor, Copilot, or Claude are writing and refactoring your code, the traditional implementation needs to evolve. At Etere Studio, we've spent the last year adapting our clean architecture Flutter projects for AI-assisted development. Here's what we've learned.

Why Traditional Clean Architecture Struggles with AI

The core issue is context windows. AI agents can only see what you show them, and they can only hold so much information at once. Traditional Clean Architecture relies heavily on:

  • Implicit layer relationships — The dependency rule is understood, not declared
  • Scattered context — Related code lives in separate directories by layer
  • Mental model assumptions — Developers "just know" that UserRepository implements IUserRepository

An AI agent sees a use case that depends on IUserRepository and has no idea where the implementation lives. It might search, find three implementations, and pick the wrong one. Or worse, create a fourth.

The solution isn't abandoning Clean Architecture. It's making the implicit explicit.

Comparison of traditional layer-first vs AI-friendly feature-first folder structures

The 8 Principles

These principles preserve Uncle Bob's core ideas—dependency inversion, separation of concerns, testability—while making your codebase navigable by AI agents.

Principle 1: Physical Boundaries Over Logical Ones

Traditional approach: organize by layer.

lib/
  domain/
    entities/
      user.dart
      order.dart
    repositories/
      user_repository.dart
      order_repository.dart
  data/
    repositories/
      user_repository_impl.dart
      order_repository_impl.dart

AI-friendly approach: organize by feature with explicit layer markers.

lib/
  features/
    user/
      domain/
        user_entity.dart
        user_repository.dart  // interface
      data/
        user_repository_impl.dart
        user_local_source.dart
        user_remote_source.dart
      presentation/
        user_cubit.dart
        user_screen.dart
      user.dart  // barrel export
    order/
      domain/
      data/
      presentation/
      order.dart

When you tell an AI "modify the user repository," it can now find everything related to users in one directory tree. The layers still exist, but they're scoped to the feature.

Principle 2: Context Files as First-Class Architecture Artifacts

This is the biggest shift. Create explicit context files that AI agents can consume.

Code comparison showing service locator vs constructor injection with AI comprehension indicators
Architecture Overview

ARCHITECTURE.md at the root:

## Pattern: Clean Architecture (Feature-First)

### Dependency Rule
Dependencies flow inward: presentation → domain ← data
Domain layer has zero external dependencies.

### Layer Responsibilities
- **domain/**: Entities, repository interfaces, use cases
- **data/**: Repository implementations, data sources, DTOs
- **presentation/**: Cubits/Blocs, widgets, screens

### Conventions
- Repository interfaces: `{name}_repository.dart`
- Repository implementations: `{name}_repository_impl.dart`
- Entities: `{name}_entity.dart`
- DTOs: `{name}_dto.dart`

### State Management
Using flutter_bloc with Cubits for simple state, Blocs for complex flows.

CONTEXT.md per feature:

# User Feature

## Purpose
Handles user authentication, profile management, and preferences.

## Key Files
- `domain/user_entity.dart` - Core user model
- `domain/user_repository.dart` - Interface for user operations
- `data/user_repository_impl.dart` - Implementation using Firebase
- `presentation/user_cubit.dart` - State management for user screens

## Dependencies
- Depends on: core/network, core/storage
- Used by: features/order, features/settings

## Testing
Run: `flutter test test/features/user/`

These files cost nothing at runtime but save hours of AI confusion.

Principle 3: Constructor Injection, Always

Service locators like GetIt are convenient for humans. They're invisible to AI.

Problematic:

class UserCubit extends Cubit<UserState> {
  UserCubit() : super(UserInitial()) {
    _repository = GetIt.I<UserRepository>();
  }

  late final UserRepository _repository;
}

An AI sees UserCubit() and has no idea it depends on UserRepository. It might generate code that instantiates the cubit without registering the dependency.

AI-friendly:

class UserCubit extends Cubit<UserState> {
  UserCubit({required this.repository}) : super(UserInitial());

  final UserRepository repository;
}

Now the dependency is visible in the constructor signature. Any AI can see exactly what this class needs.

You can still use GetIt for wiring at the composition root—just make dependencies explicit at the class level.

Principle 4: Tests as Executable Specifications

Tests aren't just for catching bugs. They're the most reliable documentation for AI agents.

void main() {
  group('UserRepository', () {
    group('getUser', () {
      test('returns user when API call succeeds', () async {
        // Arrange
        final mockApi = MockUserApi();
        when(() => mockApi.fetchUser('123'))
            .thenAnswer((_) async => UserDto(id: '123', name: 'John'));

        final repository = UserRepositoryImpl(api: mockApi);

        // Act
        final result = await repository.getUser('123');

        // Assert
        expect(result, isA<User>());
        expect(result.name, equals('John'));
      });

      test('throws UserNotFoundException when user does not exist', () async {
        // Arrange
        final mockApi = MockUserApi();
        when(() => mockApi.fetchUser('999'))
            .thenThrow(ApiException(statusCode: 404));

        final repository = UserRepositoryImpl(api: mockApi);

        // Act & Assert
        expect(
          () => repository.getUser('999'),
          throwsA(isA<UserNotFoundException>()),
        );
      });
    });
  });
}

When you ask an AI to "add caching to the user repository," well-structured tests tell it:

  • What the current behavior is
  • What patterns to follow
  • What edge cases to handle

Tests enable safe AI refactoring. Without them, you're gambling.

Principle 5: Explicit Error Types Over Generic Exceptions

Generic exceptions hide behavior from AI agents.

Problematic:

Future<User> getUser(String id) async {
  try {
    return await api.fetchUser(id);
  } catch (e) {
    throw Exception('Failed to get user');
  }
}

AI-friendly:

sealed class UserError {
  const UserError();
}

class UserNotFoundError extends UserError {
  const UserNotFoundError(this.userId);
  final String userId;
}

class UserNetworkError extends UserError {
  const UserNetworkError(this.message);
  final String message;
}

Future<Either<UserError, User>> getUser(String id) async {
  try {
    final dto = await api.fetchUser(id);
    return Right(dto.toEntity());
  } on ApiException catch (e) {
    if (e.statusCode == 404) {
      return Left(UserNotFoundError(id));
    }
    return Left(UserNetworkError(e.message));
  }
}

Sealed classes with exhaustive pattern matching make error handling visible and verifiable. AI agents can see all possible outcomes and generate appropriate handling code.

Principle 6: Co-locate Interface and Implementation

Traditional Clean Architecture puts interfaces in domain/ and implementations in data/. This separation makes sense conceptually but creates navigation problems.

Alternative structure:

features/user/
  domain/
    user_repository.dart      // interface
    user_repository_impl.dart  // implementation
    user_entity.dart

Wait—implementation in domain? Not exactly. The file lives in domain/ but the class still depends only on domain concepts. What we're co-locating is the contract and its primary implementation.

For cases with multiple implementations (like a FakeUserRepository for testing), keep the fake in test/ or a dedicated fakes/ directory.

Principle 7: Barrel Exports with Visibility Control

Barrel files (user.dart that exports everything from a feature) help AI agents understand public APIs.

// features/user/user.dart

// Public API - what other features can use
export 'domain/user_entity.dart';
export 'domain/user_repository.dart';
export 'presentation/user_cubit.dart';
export 'presentation/user_screen.dart';

// Internal - don't export
// data/user_repository_impl.dart
// data/user_dto.dart
// data/user_local_source.dart

When an AI sees import 'package:app/features/user/user.dart', it knows exactly what's available without crawling the directory.

Principle 8: Document the "Why" in Code Comments

AI agents are excellent at understanding what code does. They struggle with why certain decisions were made.

/// Repository for user operations.
/// 
/// Uses Firebase Auth for authentication and Firestore for profile data.
/// Profile data is cached locally for 24 hours to reduce Firestore reads
/// (billing optimization - we hit 50k reads/day in production).
/// 
/// See: https://firebase.google.com/pricing for read limits
abstract class UserRepository {
  /// Fetches user by ID.
  /// 
  /// Returns cached data if available and less than 24 hours old.
  /// Throws [UserNotFoundException] if user doesn't exist in Firebase.
  /// Throws [UserNetworkError] for connectivity issues.
  Future<User> getUser(String id);
}

The "billing optimization" comment prevents an AI from removing the cache in a refactor because it "simplifies the code."

Comparison: Traditional vs AI-Friendly Clean Architecture

Aspect Traditional AI-Friendly
Organization By layer By feature, then layer
Dependencies Service locator Constructor injection
Context Implicit (developer knowledge) Explicit (CONTEXT.md files)
Interfaces Separate from implementation Co-located with primary impl
Errors Generic exceptions Sealed class hierarchies
Public API Implicit Barrel exports
Rationale In developer's head In code comments

Practical Migration Path

You don't need to rewrite your codebase. Start with:

  1. Add ARCHITECTURE.md — 30 minutes, immediate benefit
  2. Add CONTEXT.md to your most complex feature — Test the impact
  3. Convert service locator to constructor injection — One class at a time
  4. Reorganize one feature — See if the structure helps your AI workflow

At Etere Studio, we migrated a 50k LOC Flutter app over 6 weeks. The AI productivity gains were noticeable within the first week, just from adding context files.

When This Doesn't Apply

These principles optimize for AI-assisted development. If you're:

  • Working solo without AI tools
  • On a small project (under 10k LOC)
  • Using a framework with strong conventions (like Rails)

…traditional Clean Architecture might serve you better. The overhead of context files and explicit everything isn't free.

But if you're building a production Flutter app with a team, and you're using AI coding assistants daily? These adaptations pay for themselves quickly.


The goal isn't to replace Clean Architecture—it's to make it legible to a new kind of collaborator. AI agents are increasingly part of how we build software. Our architecture should acknowledge that.

Working on a Flutter project and want to discuss architecture approaches? Let's talk.