AI-Generated Flutter Code Is Not Production-Ready Until It Passes These Checks
AI coding tools are no longer only autocomplete with a nicer interface.
Tools such as Codex, Claude Code, Cursor, Copilot, and other coding agents can inspect repositories, edit multiple files, run commands, generate tests, and prepare pull requests. That makes them useful for Flutter work: screens, repositories, state-management classes, API clients, migrations, automated tests, and refactors that would otherwise take a lot of repetitive effort.
But there is a dangerous gap between code that looks plausible and code that is safe to ship.
An AI agent can generate Dart code that compiles, follows common examples, passes a happy-path test, and looks correct in a quick emulator run. The same code can still violate the app architecture, rebuild too much of the widget tree, mishandle authentication, fail offline, break on iOS, leak a stream subscription, or add a dependency nobody wants to own later.
Production readiness requires evidence.
AI can accelerate Flutter development, but the engineer remains responsible for architecture, correctness, performance, security, platform behavior, testing, and release confidence. The checklist below is how I would review AI-generated Flutter code before treating it as production-ready.
1. Architecture check
The first question is not, “Does this code work?”
The first question is, “Where does this code belong?”
Flutter’s app architecture guide emphasizes separation of concerns across UI, logic, repositories, and services. The exact pattern can differ from project to project, but the principle stays the same: every component needs a clear responsibility and dependency direction.
Review the generated code against the architecture already used by the application.
- If the project uses Clean Architecture, presentation should not reach directly into infrastructure.
- If the project uses a feature-first structure, code should land in the correct feature instead of another global utilities folder.
- If the project uses MVVM, presentation logic should stay in the view model rather than drifting into widgets.
- If the project uses BLoC or Cubit, events, states, side effects, and repository dependencies should be modeled intentionally.
- If the project uses Riverpod, provider ownership, dependency scope, and lifecycle should be explicit.
- If the project uses GetX, controller lifecycle and global state must be reviewed carefully.
- If the project uses repositories, the UI should not bypass them and call APIs or databases directly.
A senior review should also check whether DTOs, database entities, and domain models have been collapsed into one class; whether a new abstraction duplicates an existing service; whether dependency injection is consistent; and whether business rules can be tested without building widgets or initializing plugins.
The architecture check passes only when the generated code looks as if it belongs in the repository, not as if it was pasted from a generic tutorial.
2. State management check
State management should make transitions predictable, observable, and testable.
The library matters less than ownership. BLoC, Riverpod, Provider, GetX, ChangeNotifier, and local widget state can all be used responsibly. The problem starts when generated code introduces a second state model for the same data or gives state to the wrong lifecycle owner.
For every generated state-management change, identify:
- The source of truth: widget, provider, BLoC, repository, cache, or server.
- The lifecycle: temporary dialog state, page state, session state, or durable application state.
- The legal transitions: idle, loading, success, empty, failure, retrying, or stale.
- The concurrency behavior: refresh twice, change filters mid-request, leave the page before completion.
- The rebuild scope: one field update should not always rebuild the entire page.
- The test boundary: transitions should be testable without a real network, database, or plugin.
Watch for code that calls setState after disposal, stores BuildContext in long-lived objects, exposes mutable collections directly, performs API calls from build, creates controllers inside frequently rebuilt methods, watches a whole state object when only one field is needed, or mixes navigation, analytics, persistence, and state mutation in one method.
Good state code makes change boring. Generated state code often needs extra review because it can make the first demo look fine while hiding lifecycle bugs that only appear after navigation, refresh, and background transitions.
3. Error handling check
Production error handling is designed behavior, not a pile of try-catch blocks.
For network-backed features, review at least these cases:
- No network connection.
- DNS or socket failure.
- Connection and response timeout.
- Request cancellation.
- Authentication or authorization failure.
- Validation failure.
- Rate limiting.
- Server failure.
- Unexpected status code.
- Empty response body.
- Malformed JSON.
- Missing or incorrectly typed fields.
- Stale cache.
- Partial data.
- Retry exhaustion.
Do not reduce every failure to Exception('Something went wrong'). The repository or service layer should map infrastructure failures into a failure model the rest of the application understands. That can be sealed failure classes, a Result<T, Failure> type, domain-specific exceptions at clear boundaries, or explicit async state objects.
The UI should then distinguish between recoverable and non-recoverable conditions. An offline state may offer cached content and retry. A validation failure may highlight a field. An expired session may trigger reauthentication. A parsing failure may require telemetry because it means the server contract changed unexpectedly.
Flutter also distinguishes errors caught by framework callbacks from asynchronous errors outside those callbacks. The Flutter error-handling documentation explains the roles of FlutterError.onError and PlatformDispatcher.instance.onError. Configure global reporting, but do not use global handlers as a substitute for local error design.
A production implementation should answer four questions for every failure:
- What does the user see?
- What does the application do next?
- What is reported to monitoring?
- What information must never be logged?
4. Performance check
Flutter performance issues are often architectural issues expressed through rendering.
The Flutter performance best practices warn against expensive work in build methods and overly large widgets. Because build can run frequently, generated code that looks harmless can become costly when placed in the wrong part of the widget tree.
Review AI-generated code for:
- Broad state subscriptions that rebuild large sections unnecessarily.
- Expensive calculations inside
build. - Large widgets with unclear rebuild boundaries.
- Long lists that do not use lazy builders or pagination.
- Image loading without correct sizing, caching, placeholder, and failure states.
- JSON parsing, encryption, image processing, or large transformations on the UI isolate.
- Controllers, streams, timers, focus nodes, and listeners without disposal or cancellation.
Split widgets by responsibility and change frequency, not merely by line count. Extracting a widget is useful when it narrows ownership, rebuild scope, or readability. It is not useful if it only hides the same problem in another file.
Profile instead of guessing. Use Flutter DevTools’ Performance view for frame behavior and the Memory view for allocation and retention patterns. Test in profile or release mode on representative devices, not only in debug mode on a fast workstation.
5. Security check
Security review should begin with data classification.
For every value touched by generated code, decide whether it is public, internal, personal, authentication-related, financial, health-related, regulated, or dangerous if exposed through logs, backups, screenshots, or local files.
Then review the full data path.
- Tokens should not live in ordinary preferences.
- Logout should clear all relevant credentials and cached sensitive state.
- Access and refresh token lifetimes should be understood separately.
- Authorization headers, passwords, tokens, personal records, and full response bodies should not be printed.
- API credentials embedded in a mobile app should be treated as client-visible, not as server secrets.
- Permissions should be requested only when the user understands why the feature needs them.
- Local databases, exports, temporary files, and backups should be reviewed for confidentiality.
Android’s Keystore system and Apple’s Keychain Services exist for platform-backed sensitive storage. Flutter secure-storage packages can wrap these facilities, but the configuration and platform behavior still need review.
Security is not complete when a package named “secure storage” appears in pubspec.yaml. The entire path from input to storage, logs, analytics, transport, backup, and deletion needs to be reviewed.
6. Platform-specific check
A shared Dart codebase does not remove platform contracts.
Flutter’s platform-channel documentation explains how Dart communicates with native Android and iOS code. Any generated native bridge should be reviewed on both sides of the channel: argument types, error mapping, thread usage, lifecycle, and plugin setup.
Test Android and iOS independently for:
- Runtime permissions.
- Notification authorization and token refresh.
- Camera, microphone, and photo-library behavior.
- WebView navigation, cookies, and downloads.
- File pickers and storage locations.
- Deep links and universal links.
- Authentication redirects.
- Keyboard and safe-area behavior.
- Platform back navigation.
- App lifecycle transitions.
- Native crashes and plugin exceptions.
Background work deserves extra scrutiny. Android and iOS do not offer identical execution guarantees. An arbitrary Dart timer does not make work reliable after an iOS app enters the background.
For plugin-based features, inspect Android and iOS setup instructions instead of assuming flutter pub add completes the integration. Native configuration may require manifest entries, Info.plist usage descriptions, capabilities, entitlements, Gradle changes, minimum deployment-target changes, ProGuard or R8 rules, URL schemes, or app delegate integration.
At minimum, run the feature on one physical Android device and one physical iOS device when those platforms are supported. Emulators and simulators are useful, but they do not reproduce every camera, notification, biometric, background-execution, memory, and vendor-specific condition.
7. Testing check
Generated code should arrive with evidence, not confidence language.
Flutter’s testing overview distinguishes unit, widget, and integration tests. A healthy suite usually contains many focused unit and widget tests plus enough integration coverage for critical user journeys.
Use unit tests for business rules, data mapping, input validation, state transitions, retry decisions, cache policies, repository behavior, and failure mapping. These should not need a device or real network.
Use widget tests for loading, success, empty, and failure states; user input; validation messages; retry actions; navigation triggers; state-dependent controls; and accessibility labels where relevant. A generated widget test that only checks whether a title exists is not enough for a stateful production feature.
Use integration tests for critical paths such as authentication, checkout, account creation, synchronization, file upload, notifications, deep links, database migration, logout, and credential removal.
Mock or fake the transport layer to verify request method, path, headers, query parameters, serialization, successful responses, error responses, malformed responses, timeouts, and retry behavior. Do not mock the class under test. Mock its external dependency.
Every production bug fix should include a regression test when the failure can be reproduced deterministically. The goal is not an arbitrary coverage percentage. The goal is confidence in risky behavior, boundaries, state transitions, and previously broken paths.
8. Dependency check
Every dependency is code your application ships or trusts.
AI agents often add packages because package-based solutions are concise and easy to generate. That convenience can hide long-term ownership cost.
Before accepting a dependency, check:
- The most recent stable release.
- Release frequency and changelog quality.
- Open issues and unresolved platform bugs.
- Maintainer or verified-publisher identity.
- Supported Android, iOS, web, and desktop targets.
- Minimum Dart and Flutter SDK requirements.
- Native installation complexity.
- Transitive dependencies.
- License compatibility.
- Security advisories.
- Whether the same result can be achieved safely with existing dependencies or the SDK.
Pub.dev’s package-scoring documentation explains Pub Points and maintainability signals. Those metrics are useful indicators, but they are not a substitute for engineering review.
Run:
flutter pub outdated
flutter pub deps
flutter analyze
Review the lockfile change as carefully as the Dart change. A one-line pubspec.yaml addition can introduce a large transitive graph.
Avoid unpinned Git dependencies in production unless the team has a deliberate reason and ownership plan. If a Git dependency is necessary, pin a reviewed commit rather than tracking a mutable branch.
9. Code review check
AI-generated code needs the same review standard as human-generated code, and often a more suspicious first pass because the author cannot explain the repository’s history unless that history was provided.
A senior developer should review beyond “Does it work?”
- Does the code fit the existing architecture?
- Is there one clear source of truth?
- Are dependencies flowing in the right direction?
- Are names consistent with the domain?
- Are error states explicit?
- Is async work cancellable or safely ignorable?
- Can callbacks execute after disposal?
- Are side effects separated from state calculation?
- Is sensitive data logged or persisted?
- Does the code create hidden platform requirements?
- Is the dependency justified?
- Are tests proving behavior or mirroring implementation?
- Will another engineer understand this code six months from now?
- Can the implementation handle realistic data volume?
- Is observability sufficient for diagnosing production failures?
- Is the diff larger than necessary?
Also review what the AI did not change. A new repository method may require dependency registration, mocks, analytics, migration logic, localization, accessibility, or platform configuration elsewhere.
AI review tools can provide an additional pass, but they do not replace accountable human approval. Treat them as extra reviewers, not final authorities.
10. Release readiness check
Code is not production-ready until it survives the release path.
Run release builds for the platforms you ship:
flutter build appbundle --release
flutter build apk --release
flutter build ipa --release
Release builds expose native configuration, signing, compiler, linker, tree-shaking, and plugin problems that hot reload may never reveal. Flutter’s Android release guide and iOS release guide are worth checking whenever release configuration changes.
Test on real devices. Include a lower-performance Android device, a modern Android device, a supported iPhone, relevant OS-version boundaries, upgrade testing from the currently released app, fresh-install testing, and offline or poor-network testing.
Validate crash reporting, release metadata, uploaded symbols, permission declarations, privacy disclosures, app size, startup, scrolling, image-heavy screens, database queries, and store compliance. Flutter’s app-size tool can help identify unexpected growth in assets, native libraries, fonts, and Dart packages.
For size review:
flutter build appbundle --analyze-size
flutter build ipa --analyze-size
Do not assume a previously accepted release proves that a new feature remains compliant. Store rules, target API requirements, privacy declarations, account-deletion obligations, permission policies, and reviewer expectations change over time.
A practical example
Suppose an AI agent generates this repository method:
class UserRepository {
Future<User> getUser(String id) async {
final response = await http.get(
Uri.parse('https://api.example.com/users/$id'),
);
return User.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
}
It compiles. It may work in a happy-path demo. It may even look like many examples online.
But a production review should notice several missing decisions:
- The HTTP client is not injected, which makes deterministic testing harder.
- The base URL is hardcoded.
- There is no timeout.
- Every HTTP status is parsed as if it were successful.
- An HTML error page or empty body can cause uncontrolled parsing failure.
- Infrastructure failures are not mapped into application-level failures.
- Authentication behavior is missing.
- DTO and domain boundaries are unclear.
- No test proves timeout, unauthorized, not-found, or malformed-response behavior.
A stronger shape makes those decisions explicit:
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
sealed class UserFailure implements Exception {
const UserFailure();
}
final class UserNotFound extends UserFailure {
const UserNotFound();
}
final class UserUnauthorized extends UserFailure {
const UserUnauthorized();
}
final class UserRequestTimedOut extends UserFailure {
const UserRequestTimedOut();
}
final class UserResponseInvalid extends UserFailure {
const UserResponseInvalid();
}
final class UserServerFailure extends UserFailure {
const UserServerFailure(this.statusCode);
final int statusCode;
}
class UserRepository {
UserRepository({
required http.Client client,
required Uri baseUri,
Duration timeout = const Duration(seconds: 10),
}) : _client = client,
_baseUri = baseUri,
_timeout = timeout;
final http.Client _client;
final Uri _baseUri;
final Duration _timeout;
Future<User> getUser(String id) async {
final uri = _baseUri.resolve('/users/$id');
try {
final response = await _client.get(uri).timeout(_timeout);
switch (response.statusCode) {
case 200:
return _parseUser(response.body);
case 401:
case 403:
throw const UserUnauthorized();
case 404:
throw const UserNotFound();
default:
throw UserServerFailure(response.statusCode);
}
} on TimeoutException {
throw const UserRequestTimedOut();
} on UserFailure {
rethrow;
} on FormatException {
throw const UserResponseInvalid();
}
}
User _parseUser(String body) {
final decoded = jsonDecode(body);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Expected a JSON object.');
}
final dto = UserDto.fromJson(decoded);
return dto.toDomain();
}
}
This example is still not universally production-ready. The final design should match the project’s conventions for authentication, result types, DTO mapping, cancellation, retries, caching, telemetry, and dependency injection.
The difference is that the second implementation makes operational assumptions visible.
From there, tests can cover a valid 200 response, malformed payloads, 401, 403, 404, server failures, timeouts, correct request construction, DTO-to-domain mapping, and UI state for each failure.
The code becomes production-ready through review and evidence, not through the sophistication of the prompt that generated it.
A workflow I would actually use
A disciplined AI-assisted Flutter workflow can be fast without becoming careless.
Start by giving the agent real context: relevant files, architecture, state-management approach, supported platforms, SDK constraints, error model, testing conventions, and a definition of done. Repository instruction files such as AGENTS.md, CLAUDE.md, or tool-specific rules help preserve stable project conventions.
Then ask the agent to explain its assumptions:
- Which existing patterns did you follow?
- Where is the source of truth?
- What failure cases are not handled?
- Which platform configuration is required?
- Which dependencies were added and why?
- What tests are still missing?
- What security assumptions did you make?
Run the formatter and analyzer:
dart format --output=none --set-exit-if-changed .
flutter analyze
Add tests and run them independently:
flutter test
Review architecture, test on Android and iOS, simplify generic abstractions, remove unnecessary comments, and inspect the final diff before committing.
Before a production release, run the release gates that match the app:
dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test
flutter build appbundle --release
flutter build ipa --release
The commit should communicate an engineering decision, not merely preserve an agent’s output.
Closing
AI-generated Flutter code is valuable because it reduces the cost of exploring solutions, producing boilerplate, writing initial tests, and implementing well-scoped changes.
It is not trustworthy merely because it compiles, renders correctly once, or comes from an advanced coding agent.
Production-quality Flutter code must pass architecture, state-management, error-handling, performance, security, platform, testing, dependency, review, and release checks. Each check answers a different question, and no single tool can answer all of them.
The most effective Flutter engineers will not reject AI-assisted development. They will build disciplined workflows around it.
Treat AI as a fast junior assistant: capable of producing useful work, exploring alternatives, and accelerating implementation. The developer remains accountable for architecture, maintainability, correctness, security, platform behavior, and release confidence.
AI can generate the code.
Engineering discipline decides whether that code is ready for users.
