AirADB is built with Clean Architecture in Flutter for Windows Desktop. This post breaks down every architectural decision — why I made it, and what I'd do differently.
"Clean Architecture doesn't slow you down. Poor architecture does — just later."
Why Clean Architecture for a Desktop Utility?
When I started AirADB, a senior developer told me: "It's a small utility, just put everything in one file." I almost listened.
I'm glad I didn't. Six weeks into development, I needed to change how IP detection worked. Because the logic lived in the Domain layer — completely separate from the UI — the change took 20 minutes. In a monolithic approach, it would have taken hours of untangling.
The Four Layers
AirADB follows a strict four-layer separation:
Presentation Layer
Contains all Flutter widgets, screens, and the ConnectionProvider (state management via Provider package).
- DashboardScreen — main app screen
- DevicesScreen — device list
- SettingsScreen — user preferences
- ConnectionProvider — the single source of truth for app state
The Presentation layer knows nothing about ADB. It only calls repository methods and reacts to state changes. This means I could completely replace the backend — swap ADB for a different tool — without touching a single widget.
Domain Layer
The heart of the application. Contains business logic and entities with zero dependencies on Flutter or external packages.
- DeviceRepository (interface) — defines what operations are possible
- DeviceInfo, DeviceList, ConnectionResult — pure Dart entities
- AdbStatus — represents ADB installation state
The Domain layer is the most important layer to get right. It defines the 'what' of your application, independent of the 'how'. If someone asked "what does AirADB do?" — the Domain layer is the answer.
Data Layer
Implements the Domain interfaces using real ADB commands.
- DeviceRepositoryImpl — concrete implementation of DeviceRepository
- AdbService — all ADB command execution lives here
- CommandExecutor — runs system processes with timeout management
The Data layer is where Flutter and Dart's Process API come in. It's the only layer that knows ADB commands exist.
Core / Utils
- ErrorMapper — translates technical exceptions to user-friendly messages
- RetryHandler — configurable retry logic with backoff
- LoggerService — timestamped logging with level control
- AppConfig — environment configuration (dev/staging/production)
- AppConstants — all magic strings and values in one place
State Management: Why Provider?
I evaluated Riverpod, BLoC, and Provider. For AirADB's complexity level, Provider was the right choice:
Why Provider
- ConnectionProvider is a single ChangeNotifier — easy to reason about
- The connection flow is sequential, not concurrent — BLoC's event streams would be overkill
- Riverpod's added complexity wasn't justified for one primary flow
When Not Provider
- If AirADB grows to manage multiple devices simultaneously, I'd reconsider Riverpod
- For complex async flows, BLoC might be better
For V1, Provider is clean and sufficient.
The Connection Flow in Code
The 7-stage connection flow in AdbService.establishWirelessConnection() is the most important method in the codebase. Each stage emits a ConnectionProgress callback — this is what drives the real-time UI updates.
| Stage 1 | checkAdbInstallation() |
| Stage 2 | getConnectedDevices() → firstAuthorizedUsbDevice |
| Stage 3 | enableTcpIpMode(deviceSerial) |
| Stage 4 | getDeviceIpAddress(deviceSerial) |
| Stage 5 | connectWireless(ipAddress) |
| Stage 6 | verifyWirelessConnection(ipAddress) |
| Stage 7 | return ConnectionResult.success() |
Every stage has early return on failure with a specific error message. The caller (ConnectionProvider) handles the result and updates UI state accordingly.
What I'd Do Differently
- Add unit tests from day one — I wrote none for V1. This is my biggest regret.
- Use Riverpod for multi-device V2 — the reactive graph will simplify concurrent device state.
- Abstract CommandExecutor behind an interface — would make mocking for tests trivial.