Startup MVPs collapse after launch when HTTP calls, JSON parsing, and business rules are written directly inside StatefulWidgets. By enforcing a pragmatic 3-layer Feature-First Clean Architecture (Presentation with Riverpod/BLoC, pure Dart Domain entities/contracts, and Data repositories with DTO mappers) alongside automated multi-viewport QA via device_preview_shot, teams can ship an MVP in 6 weeks that scales without a rewrite.
- Feature-first modular folders scale far better than layer-first monoliths once a codebase passes 25,000 lines of Dart.
- Never leak raw backend JSON DTOs into UI widgets; map them at the repository boundary into immutable Domain models.
- Use device_preview_shot during development to catch responsive layout overflows on small iPhones and large font scales immediately.

1. The False Economy of 'Quick and Dirty' Prototype Code
In our App Rescue practice we audit a lot of Flutter codebases that reached the store and then stopped moving. They share a shape. http.get() calls sit inside build() methods. JSON is parsed in the widget that displays it. A global mutable variable holds the logged-in user. Adding one feature breaks three screens, and nobody can say why without reading all of them.
The usual defence is that it was "just an MVP". But an MVP that finds users is immediately asked to do more: a second user role, offline support, a payment provider, a web build. Code written to be thrown away is now the foundation, and the team faces a rewrite at the exact moment it should be shipping.
The rewrite is avoidable. Clean structure does not slow a first release when the team starts from templates it has used before. It mostly comes down to three decisions about where code is allowed to live.
2. Feature-First Folders, Three Layers Inside Each
The first decision is to organise by feature, not by technical layer. A layer-first project has one screens folder, one models folder and one services folder. It reads well at 2,000 lines. Past roughly 25,000 lines, every change touches five distant folders and two developers cannot work on different features without colliding.
A feature-first project gives each feature its own folder with three layers inside it. A developer working on checkout stays in the checkout folder, and deleting a feature is deleting a directory.
lib/
core/ # shared: networking, errors, theme, routing
features/
catalog/
domain/ # pure Dart: entities + repository contracts
product.dart
catalog_repository.dart
data/ # API clients, DTOs, repository implementations
product_dto.dart
catalog_repository_impl.dart
presentation/ # widgets + Riverpod providers and notifiers
catalog_screen.dart
catalog_controller.dart
cart/
domain/ ...
data/ ...
presentation/ ...The dependency rule is what makes the layers mean something. Presentation may import domain. Data may import domain. Domain imports nothing from Flutter, from the network, or from the other two layers. An analyzer lint enforces this so it does not depend on code review catching it.
3. The Domain Layer: Plain Dart That Describes the Business
The domain layer holds two things: entities and repository contracts. An entity is an immutable class shaped the way the app thinks about the data, not the way the backend happens to send it. A contract is an abstract class that says what the app needs, with no word about how it is fetched.
Because this layer has no Flutter and no HTTP in it, it compiles and tests in milliseconds, and it survives a change of backend untouched.
// features/catalog/domain/product.dart
class Product {
const Product({
required this.id,
required this.name,
required this.price,
required this.inStock,
});
final String id;
final String name;
final Money price;
final bool inStock;
}
// features/catalog/domain/catalog_repository.dart
abstract interface class CatalogRepository {
Future<List<Product>> fetchProducts({required String categoryId});
Future<Product> fetchProduct(String id);
}4. The Data Layer: DTOs Stop at the Repository Boundary
The second decision is that raw backend JSON never reaches a widget. The data layer defines a DTO that mirrors the API response exactly, including its odd field names and nullable everything. The repository implementation converts each DTO into a domain entity before returning it.
This looks like duplication until the backend changes. When an API renames regular_price to price.amount, or starts returning a string where it used to return a number, the fix is one mapper function. Without the boundary, the same change is a search across every screen that reads the field.
The repository is also where failures are translated. A timeout, a 401 and a malformed body become typed failures the UI can act on, using the same sealed-class approach described in our shopify_pro_sdk caching article.
class ProductDto {
ProductDto.fromJson(Map<String, dynamic> json)
: id = json['id'].toString(),
name = json['name'] as String? ?? '',
regularPrice = json['regular_price']?.toString() ?? '0',
stockStatus = json['stock_status'] as String?;
final String id;
final String name;
final String regularPrice;
final String? stockStatus;
Product toDomain() => Product(
id: id,
name: name,
price: Money.parse(regularPrice),
inStock: stockStatus == 'instock',
);
}
class CatalogRepositoryImpl implements CatalogRepository {
CatalogRepositoryImpl(this._api);
final CatalogApi _api;
@override
Future<List<Product>> fetchProducts({required String categoryId}) async {
try {
final rows = await _api.getProducts(categoryId: categoryId);
return [for (final row in rows) ProductDto.fromJson(row).toDomain()];
} on TimeoutException {
throw const NetworkFailure();
} on FormatException {
throw const UnexpectedResponseFailure();
}
}
@override
Future<Product> fetchProduct(String id) async =>
ProductDto.fromJson(await _api.getProduct(id)).toDomain();
}5. The Presentation Layer: Riverpod Owns State, Widgets Only Render
The third decision is that widgets do not fetch, parse or decide. They read state and send user intent. In our projects that state lives in Riverpod providers, although the same structure works with BLoC.
A repository is exposed through a provider, so anything that needs it asks for the contract and never constructs the implementation. A controller exposes an AsyncValue, which already models the three states every screen has: loading, data and error. The widget handles all three with one when call, so a forgotten loading state or an unhandled error cannot slip through.
final catalogRepositoryProvider = Provider<CatalogRepository>((ref) {
return CatalogRepositoryImpl(ref.watch(catalogApiProvider));
});
final productsProvider =
FutureProvider.autoDispose.family<List<Product>, String>((ref, categoryId) {
return ref.watch(catalogRepositoryProvider).fetchProducts(
categoryId: categoryId,
);
});
class CatalogScreen extends ConsumerWidget {
const CatalogScreen({super.key, required this.categoryId});
final String categoryId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final products = ref.watch(productsProvider(categoryId));
return products.when(
loading: () => const ProductGridSkeleton(),
error: (error, _) => ErrorView(
failure: error,
onRetry: () => ref.invalidate(productsProvider(categoryId)),
),
data: (items) => ProductGrid(items: items),
);
}
}Notice what the widget does not contain: no URL, no JSON key, no try/catch. It could be handed to a designer to restyle without any risk to behaviour.
6. Testing Becomes Cheap Enough to Actually Do
Teams skip tests when tests are painful to write. This structure removes most of the pain, because each layer can be tested without the others.
Mapper tests feed a captured JSON response into a DTO and check the entity that comes out; they catch backend contract changes the day they happen. Controller tests override the repository provider with a fake that returns fixed data or throws a fixed failure, then assert on the states the controller emits. Widget tests override the same provider and check that the skeleton, the error view and the grid each appear when they should.
class FakeCatalogRepository implements CatalogRepository {
@override
Future<List<Product>> fetchProducts({required String categoryId}) async =>
const [Product(id: '1', name: 'Test', price: Money(1000), inStock: true)];
@override
Future<Product> fetchProduct(String id) => throw const NetworkFailure();
}
void main() {
test('productsProvider returns mapped products', () async {
final container = ProviderContainer(overrides: [
catalogRepositoryProvider.overrideWithValue(FakeCatalogRepository()),
]);
addTearDown(container.dispose);
final products = await container.read(productsProvider('shoes').future);
expect(products.single.name, 'Test');
});
}No mock server, no network, no emulator. A suite like this runs in seconds, which is the only reason it keeps getting run.
7. Catching Layout Bugs Before QA with device_preview_shot
Architecture keeps logic correct. It does nothing for a button that overflows on a small phone or a title that wraps badly when the user has large text enabled. Those bugs usually surface late, on a tester's device, when they are most expensive to fix.
To catch them during development we built and open-sourced device_preview_shot, which scores 160/160 on pub.dev. Wrapping the root widget in debug builds lets an engineer switch between device frames, toggle dark mode, change text scale and flip to a right-to-left locale such as Arabic or Urdu, all without leaving the running app. It also captures store screenshots with one click, which removes a day of manual work before each release.
- Check the smallest supported phone and the largest tablet for every new screen.
- Check text scale at 1.3x and above; this is where most overflows hide.
- Check one right-to-left locale even if the first release is English only.
- Check dark mode for hard-coded colours.
8. Staying Pragmatic: What We Leave Out
Clean Architecture has a reputation for ceremony, and the reputation is earned when every button press passes through a use-case class, an input port, an output port and a presenter. A six-week MVP cannot afford that, and does not need it.
- No use-case class for a call that only forwards to the repository. Add one when real business logic appears, such as combining two repositories or applying pricing rules.
- No abstraction for things that will not change. The repository contract earns its place because backends do change; a wrapper around
DateTime.now()usually does not. - No shared
utilsdumping ground. Code used by one feature lives in that feature; it moves tocorewhen a second feature needs it. - Code generation where it removes typing, not where it hides behaviour.
The test for every layer and every class is the same question: does this make the next change cheaper? If the answer is no, it is ceremony.
9. What This Looks Like on a Six-Week MVP
On a typical first release, the first week goes to scope, the data model and the project skeleton: routing, theming, networking, error types and CI. Because the skeleton is a template we have shipped many times, it is days of work, not weeks. Features are then built as vertical slices, one folder at a time, each with its domain, data and presentation layers, and a testable build goes to the client every week.
Emporium Mobile reached production in six weeks this way. More importantly, the apps keep growing afterwards without the rewrite. New roles, new payment providers and new platforms land as new feature folders and new repository implementations, not as surgery on existing screens.
If you are planning a first release, see how we scope MVP development for startups and our wider Flutter app development service. If you already have an app that has become hard to change, an App Rescue sprint starts with an audit against exactly the rules in this article.
More Engineering Insights
Book a Free Strategy Call
Speak directly with Principal Software Architect Abrar Ali on architecture, timeline, and fixed-scope delivery.
