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
StreamBuilderorFutureBuilderlatency
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
onErrorpipeline.
// 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>(
Comments
No comments yet. Start the discussion.