Beyond Clean Architecture: The Iceberg Pattern for Real-Time Flutter Apps with BlocSignal
DEV Community

Beyond Clean Architecture: The Iceberg Pattern for Real-Time Flutter Apps with BlocSignal

Why Traditional Clean Architecture Stalls in Real-Time Cloud Apps

Most enterprise Flutter tutorials preach Uncle Bob's Clean Architecture or classic BLoC layering. They show neat diagrams with concentric circles: Presentation, Use Cases / Interactors, Repositories, and Data Sources. Yet nearly every one of those tutorials demonstrates the architecture exclusively with static REST request-response endpoints or trivial counter apps. The moment you build a modern, datastore-backed application-powered by Firebase Cloud Firestore, Supabase, or real-time WebSockets-traditional Clean Architecture rapidly deteriorates into one of two anti-patterns:

The "Anemic Lasagna" Trap

Layers of pass-through classes (Controller.get() โž” UseCase.execute() โž” Repository.fetch() โž” DataSource.get()) that merely forward method invocations to the layer below without transforming data, encapsulating invariants, or providing architectural protection. You end up writing 4 files, 3 interfaces, and 20 lines of ceremonial glue for a single read operation.

Stream & Microtask Spaghetti

Trying to synchronize multiple live cloud streams (collection.snapshots(), authStateChanges(), local search text inputs) using intricate Rx pipelines (combineLatest3, switchMap), nested subscriptions, manual lifecycle cancellations, and race-condition-prone state flags.

Classical "Clean" REST Layering vs Real-Time Cloud Reality

Classical "Clean" REST Layering (Anemic Lasagna):

UI โž” Interactor โž” Repository โž” DataSource โž” REST API (Pull-only, High Ceremony)

Real-Time Cloud Reality:

Firebase / Supabase โž” Live Stream โž” ??? โž” Flutter UI (Push-heavy, Async Glitches)

The Iceberg Pattern

The Iceberg Pattern solves this dilemma. By establishing a collaborative boundary between fine-grained reactive signals and unidirectional BLoC facades, the Iceberg Pattern gives real-time Flutter apps:

  • Submerged, warm data caching that outlives transient screen navigations
  • 0ms frame-perfect optimistic mutations with automatic server reconciliation and rollback
  • Screen-scoped facades with zero pass-through ceremony
  • 100% synchronous UI rendering with zero StreamBuilder or FutureBuilder latency

The 4-Layer Architecture (Zero Pass-Throughs)

In the Iceberg Pattern, every layer has a distinct lifecycle, operates on different data structures, and addresses an indispensable, non-overlapping responsibility:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚1. PRESENTATION LAYER (FLUTTER)                                         โ”‚
โ”‚ โ€ข Lifecycle: Transient render passes                                   โ”‚
โ”‚ โ€ข Responsibilities: Pure synchronous projection (UI = ฦ’(State))        โ”‚
โ”‚ โ€ข BlocSignalBuilder for UI rendering; BlocSignalListener for toasts    โ”‚
โ”‚ โ€ข Non-blocking banner when hasSyncError == true (Stale-While-Reval)    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ฒโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                    โ”‚ Projects State & Forwards Errors
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚2. APPLICATION FACADE (TaskBoardCubit)                                  โ”‚
โ”‚ โ€ข Lifecycle: Screen-scoped (created on push, disposed on pop)          โ”‚
โ”‚ โ€ข Responsibilities: View-specific filtering, sorting, & search         โ”‚
โ”‚ โ€ข Ephemeral interaction tracking (for example isDeletingTaskId)        โ”‚
โ”‚ โ€ข Error translation: Catches repository sync errors โž” onError()        โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ฒโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                    โ”‚ Observes ReadonlySignal<List<Task>>
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~โ”‚~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~ WATERLINE (SURFACE LEVEL) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~โ”‚~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ 3. DOMAIN ENGINE & CACHE (TaskRepository)                              โ”‚
โ”‚ โ€ข Lifecycle: App/Session-scoped (survives screen navigation)           โ”‚
โ”‚ โ€ข Responsibilities: Async-to-sync collapse via private signals         โ”‚
โ”‚ โ€ข Data normalization: Maps cloud DTOs to pure Dart 3 records           โ”‚
โ”‚ โ€ข Global optimistic mutation engine with automatic rollback            โ”‚
โ”‚ โ€ข Public Edge: Exposes ReadonlySignal<List<Task>> & hasSyncError       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ฒโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                    โ”‚ Live Snapshots & Background Writes
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚4. EXTERNAL DATASTORE (FIREBASE / CLOUD)                                โ”‚
โ”‚ โ€ข Lifecycle: Remote cloud persistence & server security rules          โ”‚
โ”‚ โ€ข Raw asynchronous event streams (collection.snapshots())              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Waterline

Above the waterline (Visible): Flutter presentation widgets and the screen-scoped TaskBoardCubit. They only know about synchronous state snapshots and user intents.

Below the waterline (Submerged): The TaskRepository engine. It absorbs asynchronous cloud streams, collapses them into fine-grained reactive signals, coordinates optimistic mutations, and handles silent rollback upon network rejection.

Locked-In Architectural Decisions

Decision Area Architectural Choice Rationale & Impact
Engine Scope Repository-Scoped The repository owns the live datastore stream and in-memory cache. Data remains warm and active across route pushes and pops without redundant network queries.
Domain Model Dart 3 Records typedef Task = ({String id, String title, bool isCompleted, List<String> tags}); Zero class ceremony, structural equality out of the box, pattern matching ready.
Optimistic Scope Repository-Level Multi-screen consistency: toggling a task on a detail view is reflected synchronously on summary dashboards and widgets in frame 0.
Repo Boundary ReadonlySignal The repository keeps writable signals strictly private (_), exposing only ReadonlySignal<List<Task>> and ReadonlySignal<bool> to consumers.
Mutation Strategies Dual-Track Optimistic: 0ms local mutation + background write + rollback on failure.<br>Pessimistic: Screen tracks isDeletingTaskId spinner while awaiting cloud deletion confirmation.
Rollback UX Silent Snapback + Toast State silently snaps back to server truth; failure notifications route via CubitSignal.onError to a SnackBar. Domain models stay pure.
Cloud Resilience Stale-While-Revalidate Never replace user data with a red error box on transient disconnects. Keep cached data visible with a non-blocking warning banner.
Platform Target Pure Dart First Models, Repository, and Cubit are pure Dart-runnable on CLI, Jaspr web, server backend, or Flutter. Tested via blocSignalTest.

The Reference Implementation

Let us walk through each component of the architecture using the official reference implementation from examples/iceberg_pattern.

1. The Domain Model: Zero-Ceremony Dart 3 Records

Instead of writing 60 lines of boilerplate with copyWith, props, and constructor overrides, we model domain entities as lightweight, immutable Dart 3 records:

// domain/task.dart
typedef Task = ({
  String id,
  String title,
  bool isCompleted,
  List<String> tags,
});

Dart 3 records provide built-in structural equality, clean destructuring, and pattern matching without any code generation or external runtime dependencies.

2. The Submerged Engine: TaskRepository

The repository is the heart of the Iceberg Pattern:

  • It absorbs the raw cloud snapshot stream into a private StreamSignal.
  • It maintains private optimistic overrides (_optimisticPatches).
  • It computes the combined view of server truth and pending local mutations via computed().
  • It atomically reconciles or rolls back mutations using batch().
// data/task_repository.dart
import 'dart:async';
import 'package:signals_core/signals_core.dart';
import 'package:iceberg_pattern_example/domain/task.dart';

class SyncRollbackException implements Exception {
  SyncRollbackException(this.message, [this.cause]);
  final String message;
  final Object? cause;
  @override
  String toString() => 'SyncRollbackException: $message';
}

class TaskRepository {
  TaskRepository({
    required Stream<List<Task>> cloudSnapshotStream,
    required Future<void> Function(String id, bool isCompleted) updateCloudTask,
    required Future<void> Function(String id) deleteCloudTask,
    List<Task> initialTasks = const [],
  }) : _updateCloudTask = updateCloudTask,
       _deleteCloudTask = deleteCloudTask {
    _initEngine(cloudSnapshotStream, initialTasks);
  }

  final Future<void> Function(String id, bool isCompleted) _updateCloudTask;
  final Future<void> Function(String id) _deleteCloudTask;

  // Private Reactive Graph
  late final StreamSignal<List<Task>> _cloudStreamSignal;
  final _optimisticPatches = signal<Map<String, bool>>({});
  final _hasSyncError = signal(false);
  late final Computed<List<Task>> _computedTasks;

  void _initEngine(Stream<List<Task>> cloudSnapshotStream, List<Task> initialTasks) {
    _cloudStreamSignal = streamSignal(
      () => cloudSnapshotStream,
      options: AsyncSignalOptions<List<Task>>(initialValue: initialTasks),
    );
    _computedTasks = computed(() {
      final baseTasks = _cloudStreamSignal.value.value ?? const [];
      final overrides = _optimisticPatches.value;
      if (overrides.isEmpty) return baseTasks;
      return baseTasks.map((task) {
        final override = overrides[task.id];
        return override != null
            ? (id: task.id, title: task.title, isCompleted: override, tags: task.tags)
            : task;
      }).toList();
    });
  }

  // Public Read-Only Boundaries
  ReadonlySignal<List<Task>> get tasks => _computedTasks;
  ReadonlySignal<bool> get hasSyncError => _hasSyncError;

  /// OPTIMISTIC MUTATION: Updates state across all screens in 0ms,
  /// then synchronizes with the cloud in the background.
  Future<void> toggleTask(String id, bool currentStatus) async {
    final newStatus = !currentStatus;
    _optimisticPatches.value = {..._optimisticPatches.value, id: newStatus};
    try {
      await _updateCloudTask(id, newStatus);
      // Reconcile: clear override once the cloud confirms
      batch(() {
        _hasSyncError.value = false;
        final updated = Map<String, bool>.from(_optimisticPatches.value)..remove(id);
        _optimisticPatches.value = updated;
      });
    } catch (error, stackTrace) {
      // Rollback: silently remove patch and notify caller
      batch(() {
        final updated = Map<String, bool>.from(_optimisticPatches.value)..remove(id);
        _optimisticPatches.value = updated;
        _hasSyncError.value = true;
      });
      Error.throwWithStackTrace(
        SyncRollbackException('Failed to update task $id. Reverted.', error),
        stackTrace,
      );
    }
  }

  /// PESSIMISTIC MUTATION: Awaits server confirmation before resolving.
  Future<void> deleteTask(String id) async {
    await _deleteCloudTask(id);
  }

  void dispose() {
    _cloudStreamSignal.dispose();
    _optimisticPatches.dispose();
    _hasSyncError.dispose();
    _computedTasks.dispose();
  }
}

3. The Visible Boundary: TaskBoardCubit

The application facade is screen-scoped. When a screen mounts, it creates a TaskBoardCubit. When the screen is popped, the Cubit is closed and its effects are disposed. The Cubit:

  • Filters or sorts tasks specifically for this view without mutating the repository.
  • Tracks ephemeral UI state (for example which row is currently displaying a deletion spinner).
  • Translates repository exceptions into BLoC's standard onError pipeline.
// application/task_board_cubit.dart
import 'dart:async';
import 'package:bloc_signals/bloc_signals.dart';
import 'package:signals_core/signals_core.dart';
import 'package:iceberg_pattern_example/data/task_repository.dart';
import 'package:iceberg_pattern_example/domain/task.dart';

typedef TaskBoardState = ({
  List<Task> tasks,
  String? activeFilterTag,
  String? isDeletingTaskId,
  bool hasSyncError,
});

class TaskBoardCubit extends CubitSignal<TaskBoardState> {
  TaskBoardCubit({required TaskRepository repository})
      : _repository = repository,
        super(
          initialState: (
            tasks: repository.tasks.value,
            activeFilterTag: null,
            isDeletingTaskId: null,
            hasSyncError: repository.hasSyncError.value,
          ),
        ) {
    _initFacade();
  }

  final TaskRepository _repository;
  final _activeFilterTag = signal<String?>(null);
  final _isDeletingTaskId = signal<String?>(null);
  late final void Function() _disposeEffect;

  void _initFacade() {
    final computedState = computed(() {
      final allTasks = _repository.tasks.value;
      final filter = _activeFilterTag.value;
      final filteredTasks = filter == null
          ? allTasks
          : allTasks.where((t) => t.tags.contains(filter)).toList();
      return (
        tasks: filteredTasks,
        activeFilterTag: filter,
        isDeletingTaskId: _isDeletingTaskId.value,
        hasSyncError: _repository.hasSyncError.value,
      );
    });
    _disposeEffect = computedState.subscribe(emit);
  }

  void setFilterTag(String? tag) => _activeFilterTag.value = tag;

  /// Dispatches optimistic toggle; forwards failure to onError for SnackBar display.
  void toggleTask(String id, bool currentStatus) {
    unawaited(
      _repository.toggleTask(id, currentStatus).catchError((Object error, StackTrace st) {
        onError(error, st);
      }),
    );
  }

  /// Dispatches pessimistic delete; tracks row-level spinner in screen state.
  Future<void> deleteTask(String id) async {
    _isDeletingTaskId.value = id;
    try {
      await _repository.deleteTask(id);
    } catch (error, stackTrace) {
      onError(error, stackTrace);
    } finally {
      _isDeletingTaskId.value = null;
    }
  }

  @override
  Future<void> close() async {
    _disposeEffect();
    _activeFilterTag.dispose();
    _isDeletingTaskId.dispose();
    await super.close();
  }
}

4. Pure Synchronous Presentation Binding

In Flutter, the presentation layer becomes a direct synchronous projection of state: UI = ฦ’(State). There are no StreamBuilder widgets, no connection state checks, and no microtask latency:

// presentation/task_board_screen.dart
import 'package:bloc_signals_flutter/bloc_signals_flutter.dart';
import 'package:flutter/material.dart';
import 'package:iceberg_pattern_example/application/task_board_cubit.dart';

class TaskBoardScreen extends StatelessWidget {
  const TaskBoardScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocSignalListener<TaskBoardCubit, TaskBoardState>(
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.