DEV Community

Cover image for From Spaghetti to Structure: Why I Migrated My Production Flutter App to Clean Architecture
Utkarsh Mishra
Utkarsh Mishra

Posted on

From Spaghetti to Structure: Why I Migrated My Production Flutter App to Clean Architecture

"First make it work, then make it beautiful and robust."

I'm a junior dev. I built a real app — Anahad — that real people use. And for a while, I was proud of it.

Then it grew. And I wasn't so proud anymore.

This isn't a textbook post about Clean Architecture. This is the story of what broke, why I finally made the switch, and what actually clicked for me — told by someone who was in the trenches with a messy codebase and had to ship features at the same time.


The Mess I Was Living In

Picture this: 6 features. Each with their own screens, widgets, blocs, services, and utils — all distributed across a directory structure that looked neat on day one and became a labyrinth by month three.

Most screens were monoliths — all-in-one files with UI and logic tangled together. The ones that did use components? Everything went into a global widgets/ directory. So you'd have prop drilling across three levels just to pass something that one widget needed. Tracking the flow of data meant hopping between directories, mentally stitching together a picture of how things connected.

Error handling was... creative. I'd built my own AsyncResult<Success, Error> sealed class — functional programming vibes without the actual library. It worked at the service layer, but it was an island. Nothing else was consistent.

And the worst part? My services were tightly coupled to Dio and the remote API. No abstraction. No interface. You couldn't mock anything. You couldn't test anything. If Dio failed, the whole thing failed, and you had to dig to figure out why.

The moment I truly felt it was when I had to trace a bug and found myself drilling through 5 directories just to understand the flow. That's when I knew — this isn't scaling. This is surviving.


What Is Flutter Clean Architecture, Actually?

Before I get into the migration, let me walk you through the structure — because once it clicked, I couldn't unsee it.

Clean Architecture splits your app into three layers:
Flutter clean architecture diagram

🎨 Presentation Layer

This is everything the user sees and touches.

  • Screens — Your UI pages
  • Widgets — Reusable UI components
  • Bloc — State management; the brain between UI and business logic

The Bloc only talks to the Domain layer. It doesn't know where data comes from. Doesn't care.

🧠 Domain Layer

This is the pure heart of your app. No Flutter. No Dio. No Firebase. Just Dart.

  • Entities — Plain Dart classes representing your core data models
  • Use Cases — Single-responsibility classes that each do one thing (e.g., LoginUseCase, FetchUserProfileUseCase)
  • Repository (Interface) — Abstract contracts defining what data operations exist, not how they work

This layer has zero dependencies on anything external. It's the most stable, most testable code in your app.

🗄️ Data Layer

This is where the real world connects.

  • Models — Extend your Entities; handle JSON serialization, API mapping
  • Repository (Implementation) — Implements the Domain's repository interface
  • Data Sources — Remote (Dio, Firebase) and Local (Hive, SQLite)

The magic line? The Repository Implementation delegates data fetching to the datasource. The domain doesn't care if it's coming from the internet or a local cache.

Bloc → UseCase → Repository Interface
                        ↑
              Repository Implementation → Remote / Local Datasource
Enter fullscreen mode Exit fullscreen mode

The Migration: Incremental, Not a Rewrite

Here's the thing nobody tells you: you don't have to nuke your codebase.

I had features to ship. Users on the app. I couldn't just disappear for two weeks and "do clean architecture." So I went incremental.

I started with the auth flow — the most self-contained, most critical part of the app. My service methods were already well-written, so the migration was mostly structural:

  1. Converted service methods into Use Cases
  2. Moved Dio calls into a RemoteDataSource
  3. Created a Repository Implementation that satisfied the Domain's abstract interface
  4. Wired the Bloc to talk to the Use Case instead of the service directly

It wasn't that hard, honestly. But the payoff was immediate.

Now if I want to add offline support to Anahad? I create a LocalDataSource, and the Repository Implementation switches between them. The Bloc doesn't change. The Use Cases don't change. The Domain doesn't even know it happened.

That's the beauty.


fpdart and Functional Programming in Use Cases

Coming from a Rust background, I was already comfortable with Option and Result types. I'd even hacked together my own AsyncResult<Success, Error> sealed class before this migration.

When I discovered fpdart, it was like: oh, this is just the proper version of what I was already doing.

In my Use Cases, I now use Either<Failure, Success> for all return types. Left is failure, right is success — and you chain transformations cleanly without nested try-catches or nullable hell.

// Use Case
class LoginUseCase {
  final AuthRepository repository;

  LoginUseCase(this.repository);

  Future<Either<Failure, User>> call(LoginParams params) async {
    return await repository.login(params.email, params.password);
  }
}
Enter fullscreen mode Exit fullscreen mode
// In the Bloc
final result = await loginUseCase(LoginParams(email: email, password: password));

result.fold(
  (failure) => emit(AuthError(failure.message)),
  (user)    => emit(AuthSuccess(user)),
);
Enter fullscreen mode Exit fullscreen mode

No try-catch spaghetti. No "did this return null or did it actually fail?" ambiguity. The types tell the story.


The "Aha" Moment

I'll be honest — my first reaction to Clean Architecture was "this is way too much boilerplate."

Why write a Use Case class that just calls a repository method? Why create an abstract interface when I could just use the implementation directly? Seemed like over-engineering for the sake of it.

Then I migrated the auth flow. And something just... clicked.

I was looking at my Bloc, and I realized: this Bloc has no idea where the user data comes from. It doesn't import Dio. It doesn't know about Firebase. It just calls loginUseCase() and reacts to the result.

Before this, I couldn't test anything because everything was tightly coupled. The service needed Dio, Dio needed a real network, the network needed... you get it. Now, because the Repository is an abstract interface, I can mock it in tests trivially:

class MockAuthRepository extends Mock implements AuthRepository {}
Enter fullscreen mode Exit fullscreen mode

That's it. Inject it. Test your Use Cases in complete isolation.

Ambiguity went away. I know exactly what each file does, what it depends on, and what it's responsible for. When something breaks, I know which layer to look at.


Should You Use Clean Architecture for Every App?

No. Please don't.

If you're building a weekend project or learning Flutter for the first time, don't start with Clean Architecture. You'll spend more time setting up the structure than building the thing, and you'll learn why the architecture exists without ever feeling the pain it solves.

The pain is the teacher.

Build the app. Ship it. Let it grow. Feel the moment where you're drilling through 5 directories to trace a bug, where you can't mock a thing for testing, where adding one feature means touching 8 files in 6 different folders.

Then reach for Clean Architecture. Because then you'll understand it — not just intellectually, but in your bones.

The architecture exists to solve real problems. Let the problems teach you first.


Quick Reference: The Layer Cheat Sheet

Layer Contains Depends On
Presentation Screens, Widgets, Bloc Domain only
Domain Entities, Use Cases, Repository Interface Nothing (pure Dart)
Data Models, Repository Impl, Data Sources Domain interfaces

What's Next for Anahad

  • Migrating the remaining 5 features incrementally
  • Adding a proper logging layer
  • Writing actual unit tests now that mocking is possible
  • Maybe offline support — and thanks to clean architecture, it'll be a datasource swap, not a rewrite

If you're a fellow junior dev with a production app that's starting to smell — I see you. You don't have to have everything figured out from day one. Build it, feel the pain, then build it better.

That's not a failure. That's engineering.


Built with love (and a lot of refactoring) while working on Anahad.
Hit me up on Twitter/X if this resonated — always happy to talk Flutter, architecture, or why Rust spoils you for everything else.

Top comments (0)