The Fundamental Question: Where Does This Code Belong?
In Part 1 of this series, I discussed the engineering decisions behind our incremental feature-by-feature migration in Holigo and why the Ship (PELNI) ticketing module was selected as our pilot blueprint. Once the core foundation was in place, the immediate challenge shifted to feature-level architecture: how to divide responsibilities so that the codebase remains clean, consistent, and intuitive for every developer on the team.
The single biggest pain point in our legacy codebase was the absence of rigid architectural boundaries. Each developer constructed screens according to personal preference. As a result, whenever a bug surfaced or new business logic was introduced, the deceptively simple question—"which file should this code live in?"—inevitably triggered a prolonged investigation and yielded contradictory answers across different screens.
The primary objective of the new architecture was straightforward: that question must have the exact same predictable answer in every feature across the app. We structured every feature module into three decoupled layers: Presentation, Domain, and Data, strictly enforcing the rule that UI widgets are prohibited from communicating directly with remote network clients or local database storage.
Core principle: The UI never touches the network or storage directly. Domain remains pure without external dependencies.
- •Presentation: Pages, widgets, and BLoCs handling user interactions and rendering states.
- •Domain (Pure Dart): Business entities, repository interfaces, and use cases that are 100% testable.
- •Data Layer: Repository implementations, remote API clients, local cache, and model parsing.
- •Model vs Entity: Data Models parse raw JSON and map cleanly into pure Domain Entities.
Core Lesson: Strict layer separation ensures technical modifications in local SQLite or API payloads never break domain business rules or UI presentation.
Ship Ticketing Case Study: Tracing Data Flow from Screen to API
The Ship (PELNI) ticketing feature served as our prime laboratory because it encompasses dynamic user interactions, external REST API integrations, and local database caching for recent routes. Let's trace how a real-world booking search flows through these three layers in sequence.
Everything begins when the user selects departure and arrival harbours and taps "Search Schedule". The screen in the Presentation layer never initiates an HTTP request directly; instead, it dispatches a SearchShipSchedule event to ShipBloc. The BLoC then invokes an isolated use case in the Domain layer—GetShipSchedulesUseCase—which validates date parameters and harbour IDs before proceeding.
The use case only depends on an abstract interface: ShipRepository. Its concrete implementation lives in the Data layer (ShipRepositoryImpl), which smartly splits the workload: fetching real-time vessel schedules from the Remote Data Source while querying offline route history from the Local Data Source via SQLite. The raw JSON payload is parsed into data models, mapped into pure Domain Entities, and returned to ShipBloc, which emits a clean ShipScheduleLoaded state for the UI to render.
Screen & BLoC
PresentationUser picks route & taps 'Search'. Screen dispatches SearchShipSchedule event to ShipBloc.
Use Case Execution
Domain LayerShipBloc invokes GetShipSchedulesUseCase validating harbour parameters and departure date.
Repository & Data Source
Data LayerShipRepositoryImpl queries Remote API for live berths and Local SQLite for recent routes.
Mapping & Render
State EmissionJSON parsed to Domain Entities. ShipBloc emits ShipScheduleLoaded state to the UI.
Core Lesson: A single BLoC orchestrates the complete feature pipeline deterministically, so any developer instantly knows where to trace and debug logic.
Production Resilience: Standardized Error Handling and Dual Data Sources
In transactional consumer applications, failure handling requires the same architectural rigor as the happy path. In our legacy app, low-level network exceptions like connection timeouts or 500 status codes frequently slipped unhandled straight into UI widgets, causing abrupt crashes or white screens that eroded user trust.
To resolve this, we introduced a functional result pattern powered by Either<AppException, T> across all repository methods. The Data layer acts as an impenetrable shield: low-level Dio exceptions, socket timeouts, and JSON serialization bugs are intercepted and converted into structured AppException subclasses. The BLoC state then tracks the request lifecycle using a concise enum (initial, loading, success, failure), allowing screens to render distinct UI states without ever dealing with messy, scattered try-catch blocks.
This strict abstraction equally governs the coexistence of local and remote data sources. In the Ship feature, harbour master lists and recent searches reside in local SQLite for instantaneous offline retrieval, whereas live departures and ticket quotas are retrieved live from server endpoints. Because both sources sit concealed behind a unified repository contract, the Presentation and Domain layers remain entirely agnostic of data origins; if we ever migrate our caching strategy from SQLite to Hive or Secure Storage, that technical change remains 100% confined to the Data layer.
- 01Functional Result: Repository returns Either type: Left carries AppException, Right carries data payload.
- 02Zero Exception Leak: Low-level network and parsing exceptions are intercepted and converted in the Data layer.
- 03Enum Request Status: BLoC state tracks operation status via clean enums (initial, loading, success, failure).
- 01Local Storage: Recent search queries and offline harbour master datasets stored in local SQLite.
- 02Remote Live API: Vessel schedules, berth availability, and dynamic pricing fetched live from API.
- 03Complete Abstraction: BLoC remains agnostic of data origins; cache strategy changes stay contained in repo.
Core Lesson: Standardizing failure pipelines via functional results and shielding data origins behind a unified repository makes code maintenance and testing remarkably predictable.
Directory Structure and Trade-offs: Sustaining Long-Term Team Velocity
At the filesystem level, we organized the entire codebase into two primary root directories: core/ and features/. The core/ directory houses shared cross-cutting infrastructure used across multiple domains—such as the routing bridge, theme tokens, the centralized Dio network client, and dependency injection service locators. Meanwhile, each business capability lives inside an isolated folder under features/<feature_name>, self-contained with its own presentation/, domain/, and data/ subdirectories.
Our organizational guideline is simple yet non-negotiable: if a piece of code is shared across multiple domains, it belongs in core; if it pertains strictly to a single business capability, it must remain inside that feature. This clear rule rescued our team from the classic pitfall where shared utility directories gradually morph into an unmaintainable dumping ground for disorganized code.
Naturally, Clean Architecture introduces clear trade-offs. Even small feature tweaks now require touching multiple distinct files: models, entities, use cases, repository contracts, implementations, and BLoC event-state pairs. For tiny prototypes, this structure can feel excessively ceremonial. Yet as our feature count expanded and new developers joined the mobile team, this upfront investment paid for itself tenfold through friction-free onboarding, safe refactoring, and freedom from unexpected regressions.
Central router, theme tokens, HTTP client, service locator, and shared cross-feature widgets.
Self-contained folder per business domain (ship, hotel, flight, promo) with their own 3 layers.
Small changes span multiple files (model, entity, use case, repo, BLoC). Can feel heavy for tiny apps.
Fast onboarding, flawless error isolation, zero unexpected regressions when shipping multi-team features.
Core Lesson: The golden rule 'if shared, put it in core; if feature-specific, keep it in the feature' is the single most effective safeguard against common utility folders turning into architectural junk drawers.
What You Can Take from This Story
If you're establishing architectural standards for your own mobile codebase, here are core principles you can immediately put to work:
Separate shared foundations from feature-specific code: Enforcing a strict boundary between core/ and features/ prevents common utility directories from turning into an unmanaged code junkyard.
Keep the UI layer completely unaware of network clients: When widgets only listen to BLoC states and are prohibited from touching HTTP clients, swapping data sources and testing business logic becomes trivial.
Standardize success and failure paths with functional results: Returning explicit Either types eliminates fragile try-catch sprawl and prevents low-level exceptions from crashing the presentation layer.
Use a single feature as your living architectural reference: The first migrated feature (like Ship in Holigo) serves as a concrete blueprint, allowing incoming team members to duplicate proven conventions without ambiguity.
Closing
The three-layer Clean Architecture pattern is neither magic nor rigid dogma. It is simply a disciplined, systematic way to ensure that the question "where does this code belong?" always receives a consistent and predictable answer.
In Part 3 of this series, we will examine the engine behind our state management: how we orchestrated BLoC streams, structured dependency injection using GetIt/Injectable, and balanced performance trade-offs in production.
Thanks for reading my story :)
P.S. This series is written at the level of software design and system architecture, without disclosing proprietary code or internal company data. If you have questions about layer separation in Flutter or want to swap migration war stories, feel free to connect and discuss!
