# GenUI Meets the VGV Architecture

> What the Official GenUI Samples Don't Show You

- Source: https://verygood.ventures/blog/flutter-genui-meets-the-vgv-architecture/
- Published: 2026-04-17
- Author: Brian Egan
- Tags: Flutter, GenUI, Bloc, Architecture, AI, Firebase

---

![GenUI with VGV architecture — side-by-side Flutter device mockups showing a plain chat interface transforming into a production-grade financial dashboard using Flutter's GenUI SDK](/assets/images/blog/genui-meets-the-vgv-architecture/main.png)

Flutter's [GenUI SDK](https://docs.flutter.dev/ai/genui) is a framework for building generative UI — you define a catalog of widgets with JSON schemas, and an LLM decides which widgets to render, with what data, at runtime. The [official samples](https://github.com/flutter/genui/tree/main/examples) are intentionally simple: they teach you how GenUI works, not how to architect a Flutter app around it. That's the right call for a learning resource, but it leaves a gap when you're building for production.

At Very Good Ventures, we built a [Life Goal Simulator](https://verygood.ventures/events/google-cloud-next-2026/) — a GenUI-powered financial planning assistant for [Google Cloud Next 2026](https://verygood.ventures/events/google-cloud-next-2026/) that walks users through a personalized, multi-step conversation, rendering custom financial widgets in real time. The simulator uses the same GenUI SDK as the official samples, but the architecture we applied looks quite different.

This post walks through how we structured it, why we made the choices we did, and how VGV's architecture principles apply to this new category of AI-driven UI.

## The Starting Point: How the Samples Work

The [official GenUI travel_app sample](https://github.com/flutter/genui/tree/main/examples/travel_app) is a great way to learn the SDK. Its architecture is intentionally flat — everything lives in a single `StatefulWidget`:

```dart
// travel_planner_page.dart — the entire app in one file
class _TravelPlannerPageState extends State<TravelPlannerPage> {
  late final SurfaceController _surfaceController;
  late final A2uiTransportAdapter _adapter;
  late final Conversation _uiConversation;
  late final GoogleGenerativeAiClient _client;
  final _messages = ValueNotifier<List<ChatMessage>>([]);
  final _isProcessing = ValueNotifier<bool>(false);
  // ... setup, streaming, rendering all in one place
}
```

The widget creates the AI client, manages the conversation, handles streaming, and renders the UI. State lives in `ValueNotifier`. There's no repository, no state management library, and no dependency injection. For understanding GenUI, this is ideal: read one file and you can understand the entire flow.

But in production, this structure creates problems:

- **Testability**: You can't unit test the conversation logic without rendering the entire widget tree.
- **Swappability**: Changing the AI provider means rewriting the presentation layer.
- **Complexity management**: As you add error handling, loading states, page navigation, and 26 custom widgets, one file becomes unmanageable.
- **Team collaboration**: Multiple developers can't work on the AI integration and the UI simultaneously without constant merge conflicts.

## VGV's Layered Architecture

VGV applies a [four-layer architecture](https://engineering.verygood.ventures/development/architecture/architecture/) with strict unidirectional data flow — the same structure we use across every Flutter project.

![VGV four-layer Flutter architecture diagram — Presentation, Business Logic, Repository, and Data layers with unidirectional data flow arrows, the architectural pattern applied to Flutter's GenUI SDK](/assets/images/blog/genui-meets-the-vgv-architecture/four_layers.png)

Each layer only talks to the one directly below it. The presentation layer never touches the API client. The Bloc never imports Flutter (keeping it framework-agnostic). The repository never depends on other repositories.

This isn't a GenUI-specific pattern — it's the same architecture we use for every Flutter project. The insight is that **GenUI doesn't require a new architecture**. The LLM is just another data source, and the GenUI SDK is just another API client. The same layered separation that works for REST APIs and databases works for generative AI.

Here's how the Flutter GenUI architecture maps to the Life Goal Simulator:

```
lib/simulator/
├── view/               # Presentation layer
│   ├── simulator_page.dart
│   ├── simulator_view.dart
│   └── widgets/
├── bloc/               # Business logic layer
│   ├── display_message.dart
│   ├── simulator_bloc.dart
│   ├── simulator_event.dart
│   └── simulator_state.dart
├── repository/         # Repository layer
│   ├── simulator_repository.dart
│   └── simulator_conversation_event.dart
├── catalog/            # GenUI component definitions
│   ├── finance_catalog.dart
│   └── items/          # 26 custom catalog items
└── prompt/             # LLM prompt construction
    └── prompt.dart
```

To see how this works, let's trace a message through each layer, starting from the bottom.

## The Repository: Wrapping the GenUI SDK

![SimulatorRepository dependency injection diagram — ChatModel, Catalog, SurfaceController, and ErrorReportingRepository injected into the central repository, which emits a typed event stream for the Bloc layer](/assets/images/blog/genui-meets-the-vgv-architecture/repository.png)

The `SimulatorRepository` is where GenUI's moving parts disappear behind a two-method API. It receives the `Catalog` and `SurfaceController` from the presentation layer (since those are rendering concerns), then wires them together with the `A2uiTransportAdapter` and `PromptBuilder` behind a clean interface:

```dart
class SimulatorRepository {
  SimulatorRepository({
    required ChatModel chatModel,
    required ErrorReportingRepository errorReporting,
    required Catalog catalog,
    required SurfaceController surfaceController,
  });

  Stream<SimulatorConversationEvent> get events => _controller.stream;

  Future<void> startConversation() async { /* ... */ }

  Future<void> sendMessage(String text) async { /* ... */ }

  Future<void> dispose() async { /* ... */ }
}
```

That's the entire public API. Every dependency is injected:

- **`ChatModel`** is the abstract interface from [Dartantic](https://pub.dev/packages/dartantic_ai). The `SimulatorRepository` doesn't know whether it's talking to Firebase AI, Google AI, Anthropic, or any other provider — it just calls `sendStream()`. The `SimulatorPage` decides which concrete implementation to use (currently `FirebaseAIChatModel`), making provider swaps a one-line change.
- **`Catalog`** and **`SurfaceController`** are rendering concerns created by the `SimulatorPage` — the repository uses the catalog for prompt building and the controller for conversation wiring, but doesn't own them.

`startConversation()` composes the system prompt, creates the transport adapter, and starts the conversation. `sendMessage()` sends a user message. The repository exposes a `Stream<SimulatorConversationEvent>` that the Bloc subscribes to.

This split is deliberate. The catalog defines *what widgets can render* — a presentation concern. The `SurfaceController` *renders surfaces* — also presentation. The `ChatModel` is the AI backend — a data concern owned by the composition root. The repository wires them together but creates none of them, staying focused on conversation management.

### Domain Event Mapping

The key design decision is the **domain event mapping**. GenUI's internal `ConversationEvent` types are implementation details — part of the [A2UI protocol](https://docs.flutter.dev/ai/genui) (Google's Agent-to-UI standard) that the SDK uses under the hood. The repository translates them into domain-specific [sealed classes](https://dart.dev/language/class-modifiers#sealed):

```dart
sealed class SimulatorConversationEvent {
  const SimulatorConversationEvent();
}

final class SimulatorConversationWaiting extends SimulatorConversationEvent {
  const SimulatorConversationWaiting({required this.isWaiting});
  final bool isWaiting;
}

final class SimulatorConversationTextReceived extends SimulatorConversationEvent {
  const SimulatorConversationTextReceived(this.text);
  final String text;
}

final class SimulatorConversationSurfaceAdded extends SimulatorConversationEvent {
  const SimulatorConversationSurfaceAdded(this.surfaceId);
  final String surfaceId;
}

final class SimulatorConversationError extends SimulatorConversationEvent {
  const SimulatorConversationError(this.message);
  final String message;
}
```

This mapping matters because it creates a **seam**. If the GenUI SDK changes its event types (and it will — it's [in alpha](https://docs.flutter.dev/ai/genui)), only the repository needs updating. The Bloc and presentation layer are completely insulated.

Inside the repository, the streaming pipeline handles the LLM-to-GenUI bridge:

```dart
Future<void> _handleSend(ChatMessage message) async {
  _history.add(_convertDataPartsToText(message));

  final messages = [
    ChatMessage.system(_systemPrompt),
    ..._history,
  ];

  final adapter = _conversation!.transport as A2uiTransportAdapter;
  final buffer = StringBuffer();

  try {
    await for (final result in _chatModel.sendStream(messages)) {
      final text = result.output.text;
      if (text.isNotEmpty) {
        buffer.write(text);
        adapter.addChunk(text);
      }
    }
    _history.add(ChatMessage.model(buffer.toString()));
  } on Object catch (e, st) {
    if (buffer.isNotEmpty) {
      // Save whatever was streamed so retry has full context.
      _history.add(ChatMessage.model(buffer.toString()));
    } else {
      // Zero chunks arrived — pop the dangling user message so history
      // doesn't end with two consecutive user turns on the next send,
      // which Firebase AI rejects as INVALID_ARGUMENT.
      _history.removeLast();
    }
    await _errorReporting.recordError(e, st, reason: 'AI sendStream error');
    _controller.add(SimulatorConversationError('AI error: $e'));
  }
}
```

Each chunk from the LLM is fed to the `A2uiTransportAdapter`, which parses the JSON and tells the `SurfaceController` to update the UI. The repository manages the message history, system prompt, and the streaming lifecycle — none of which the Bloc needs to know about.

You'll notice the repository manages `_history` (the list of `ChatMessage`s) directly rather than delegating to a separate data source. In a strict layered architecture, message history could be its own data layer concern — a local database or in-memory store that the repository composes. We kept it inline because the history is tightly coupled to the streaming lifecycle: each `_handleSend` call appends the outgoing message, streams the response, and appends the completed response in a single transaction. Extracting it would add a layer of indirection without a clear benefit today. If we later needed persistence across sessions, or multiple features sharing the same conversation history, that's when the extraction would earn its keep.

## The Bloc: Managing Conversation State

The `SimulatorBloc` consumes the repository's event stream and manages the UI state. It follows [VGV's standard Bloc conventions](https://engineering.verygood.ventures/development/state_management/bloc_state_handling/) — if you've worked with [`flutter_bloc`](https://pub.dev/packages/flutter_bloc), these patterns will look familiar: sealed events with past-tense naming, a single state class with a status enum and `copyWith`.

```dart
// Events — past tense, describing what happened
sealed class SimulatorEvent {
  const SimulatorEvent();
}
final class SimulatorStarted extends SimulatorEvent { /* ... */ }
final class SimulatorMessageSent extends SimulatorEvent { /* ... */ }
final class SimulatorSurfaceReceived extends SimulatorEvent { /* ... */ }
final class SimulatorContentReceived extends SimulatorEvent { /* ... */ }
final class SimulatorLoadingChanged extends SimulatorEvent { /* ... */ }
final class SimulatorErrorOccurred extends SimulatorEvent { /* ... */ }
final class SimulatorRetried extends SimulatorEvent { /* ... */ }
// ... plus loading overlay events
```

```dart
// State — Equatable + status enum + copyWith
final class SimulatorState extends Equatable {
  const SimulatorState({
    this.status = SimulatorStatus.initial,
    this.pages = const [],
    this.currentPageIndex = 0,
    this.isLoading = false,
    this.pendingPageIndex,
    this.showLoadingOverlay = false,
    this.error,
  });

  final SimulatorStatus status;
  final List<List<DisplayMessage>> pages;
  final int currentPageIndex;
  final bool isLoading;
  final int? pendingPageIndex;
  final bool showLoadingOverlay;
  final String? error;

  @override
  List<Object?> get props => [
    status, pages, currentPageIndex, isLoading,
    pendingPageIndex, showLoadingOverlay, error,
  ];
}
```

The state models the UI as a list of **pages**, where each page contains a list of **display messages**. Display messages are themselves a sealed hierarchy:

```dart
sealed class DisplayMessage extends Equatable {
  const DisplayMessage();
}
final class UserDisplayMessage extends DisplayMessage { /* ... */ }
final class AiTextDisplayMessage extends DisplayMessage { /* ... */ }
final class AiSurfaceDisplayMessage extends DisplayMessage { /* ... */ }
```

This is a GenUI-specific modeling choice. In a traditional chat app, you'd have a flat list of messages. Here, because GenUI surfaces are full-screen interactive UIs (not chat bubbles), each surface gets its own page. When the AI creates a new surface, the Bloc adds a new page and animates to it:

```dart
void _onSurfaceReceived(
  SimulatorSurfaceReceived event,
  Emitter<SimulatorState> emit,
) {
  final existingPageIndex = state.pages.indexWhere(
    (page) => page.any(
      (m) => m is AiSurfaceDisplayMessage && m.surfaceId == event.surfaceId,
    ),
  );

  if (existingPageIndex != -1) {
    // Surface already exists — navigate to it
    emit(state.copyWith(currentPageIndex: existingPageIndex));
  } else {
    // New surface — create a new page
    final message = AiSurfaceDisplayMessage(event.surfaceId);
    final pages = [...state.pages, <DisplayMessage>[message]];
    emit(state.copyWith(pages: pages, currentPageIndex: pages.length - 1));
  }
}
```

The Bloc also handles text streaming — as the LLM responds, text chunks arrive one at a time and get concatenated into the current page's last text message. This is a detail the presentation layer doesn't need to think about. For more on how Bloc works with stream subscriptions like this, see [How to Use Bloc with Streams and Concurrency](https://verygood.ventures/blog/how-to-use-bloc-with-streams-and-concurrency/).

## The Presentation Layer: Page Creates, View Renders

The presentation follows VGV's standard Page/View split:

**`SimulatorPage`** is the composition root. It creates the `Catalog` and `SurfaceController` (rendering concerns), injects them into the repository, and passes the `SurfaceController` directly to the view — keeping GenUI types out of the Bloc entirely:

```dart
class SimulatorPage extends StatefulWidget {
  @override
  State<SimulatorPage> createState() => _SimulatorPageState();
}

class _SimulatorPageState extends State<SimulatorPage> {
  late final Catalog _catalog;
  late final SurfaceController _surfaceController;

  @override
  void initState() {
    super.initState();
    _catalog = buildFinanceCatalog();
    _surfaceController = SurfaceController(catalogs: [_catalog]);
  }

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) =>
          SimulatorBloc(
            simulatorRepository: SimulatorRepository(
              chatModel: FirebaseAIChatModel(...),
              errorReporting: DevErrorReportingRepository()
              catalog: _catalog,
              surfaceController: _surfaceController,
            ),
          )..add(SimulatorStarted(...)),
      child: SimulatorView(
        profileType: widget.profileType,
        surfaceHost: _surfaceController,
      ),
    );
  }
}
```

The `SurfaceController` flows from the Page to the view *and* to the repository — but never through the Bloc. The Bloc state contains only plain Dart types: strings, enums, lists of `DisplayMessage`. No GenUI imports.

**`SimulatorView`** renders the UI based on Bloc state. It uses a `BlocConsumer` — `listener` for page navigation animations, `builder` for the widget tree:

```dart
BlocConsumer<SimulatorBloc, SimulatorState>(
  listenWhen: (previous, current) =>
      previous.currentPageIndex != current.currentPageIndex,
  listener: (context, state) {
    _pageController.animateToPage(
      state.currentPageIndex,
      duration: const Duration(milliseconds: 1200),
      curve: Curves.easeInOutCubic,
    );
  },
  builder: (context, state) {
    // Render pages with GenUI Surface widgets
  },
)
```

The view never touches the repository or the AI client. It reads pages from the Bloc state, renders `Surface` widgets from the GenUI SDK for each `AiSurfaceDisplayMessage`, and dispatches events when the user interacts.

## The Catalog: Domain-Specific Components as a First-Class Concern

![Life Goal Simulator financial widget catalog on iPhone — metric cards, line chart, and slider components from the 26-item Flutter GenUI catalog rendered as a production financial planning UI](/assets/images/blog/genui-meets-the-vgv-architecture/catalog.png)

The official samples define their catalog inline with the page. In the Life Goal Simulator, the catalog is a separate, structured layer with **26 custom financial widgets**, each defined in its own file. (For an introduction to building catalog items, see VGV's [Flutter GenUI shopping assistant tutorial](https://verygood.ventures/blog/flutter-genui-shopping-assistant-tutorial/).)

```
catalog/
├── finance_catalog.dart      # Composes the full catalog
└── items/
    ├── metric_cards_catalog_item.dart
    ├── line_chart_catalog_item.dart
    ├── pie_chart_catalog_item.dart
    ├── gcn_slider_catalog_item.dart
    ├── radio_card_catalog_item.dart
    ├── filter_bar_catalog_item.dart
    └── ... (26 items total)
```

Each catalog item follows a consistent structure: a JSON schema (telling the LLM what properties it can set), and a widget builder (turning that JSON into Flutter widgets):

```dart
final _schema = S.object(
  description: 'A grid of cards highlighting key financial metrics...',
  properties: {
    'cards': S.list(
      items: S.object(
        properties: {
          'label': A2uiSchemas.stringReference(
            description: 'Short label (e.g. "Fixed costs").',
          ),
          'value': A2uiSchemas.stringReference(
            description: r'Primary value (e.g. "$4,319").',
          ),
          'delta': A2uiSchemas.stringReference(
            description: 'Optional delta text (e.g. "+1.2%").',
          ),
        },
        required: ['label', 'value'],
      ),
    ),
  },
);

final metricCardsItem = CatalogItem(
  name: 'MetricCard',
  dataSchema: _schema,
  widgetBuilder: (ctx) {
    final json = ctx.data as Map<String, Object?>;
    final rawCards = json['cards']! as List;
    // ... parse JSON, build MetricCard widgets
    return MetricCardsLayout(cards: cards);
  },
);
```

The `buildFinanceCatalog()` function composes the full catalog by starting with the SDK's basic items, removing the generic ones we don't need, and adding our domain-specific widgets:

```dart
Catalog buildFinanceCatalog() {
  return BasicCatalogItems.asCatalog()
      .copyWithout(
        itemsToRemove: [
          BasicCatalogItems.button,
          BasicCatalogItems.checkBox,
          BasicCatalogItems.slider,
          // ... remove 15 generic widgets
        ],
      )
      .copyWith(
        catalogId: financeCatalogId,
        newItems: [
          metricCardsItem,
          lineChartItem,
          pieChartItem,
          gcnSliderItem,
          // ... 26 domain-specific widgets
        ],
        systemPromptFragments: [_financeWidgetRules],
      );
}
```

This separation pays off in three ways:

1. **Each catalog item is independently testable.** We have unit tests for every single one — verifying schema parsing, widget rendering, event dispatching, and data model bindings. These granular tests also serve as reliable feedback signals for CI pipelines and AI-assisted development workflows.
2. **The catalog is reusable.** If we build another financial planning feature, we can import the same catalog without duplicating widget definitions.
3. **Prompt engineering is co-located with the schema.** The `_financeWidgetRules` string tells the LLM *when* to use each widget, while the JSON schema tells it *how*. Both live in the catalog layer, not scattered across the app. Co-locating prompt fragments with their schemas reduces mismatches between what the LLM is told to render and what the catalog can actually build.

## Prompt Management: Separated from UI

The system prompt is another concern that the official samples embed directly in the widget. In the Life Goal Simulator, prompt construction is its own module:

```dart
class PromptBuilder {
  /// The system prompt that defines the AI's persona and rules.
  static String buildSystemPrompt() {
    return r'''
    You are a knowledgeable, empathetic life goal simulator...

    ## Conversation Flow
    You drive the conversation step by step...

    ## Summary Screen (REQUIRED)
    After gathering enough information...
    ''';
  }

  /// The initial user message from onboarding selections.
  static String buildInitialUserMessage({
    required ProfileType profileType,
    Set<FocusOption> focusOptions = const {},
    String customOption = '',
  }) {
    // Compose the opening message based on user's profile
  }
}
```

This separation means the team working on prompt engineering doesn't need to touch the widget code, and the prompt can be tested in isolation — verifying that different profile types and focus options produce the expected initial messages. If you later need to update prompts without redeploying, Firebase Remote Config or Firebase AI's prompt templates are straightforward drop-in replacements — the `PromptBuilder` class gives you a single integration point.

## Testability: Everything in Isolation

![Flutter GenUI test architecture — three independent test layers showing widget tests with mocked Bloc, Bloc tests with mocked repository, and catalog item tests requiring no LLM or network connection](/assets/images/blog/genui-meets-the-vgv-architecture/tests.png)

Because every layer is separated, every layer is independently testable. The test directory mirrors `lib/` exactly:

```
test/simulator/
├── bloc/           # Bloc tests with mocked repository
├── catalog/items/  # Tests for all 26 catalog items
├── prompt/         # Prompt builder tests
└── view/           # Widget tests with mocked Bloc
```

**Bloc tests** mock the repository and verify state transitions using [`blocTest`](https://pub.dev/packages/bloc_test):

```dart
blocTest<SimulatorBloc, SimulatorState>(
  'SimulatorSurfaceReceived creates a new page',
  build: () {
    when(() => repository.events).thenAnswer((_) => Stream.empty());
    return SimulatorBloc(simulatorRepository: repository);
  },
  act: (bloc) => bloc.add(const SimulatorSurfaceReceived('surface-1')),
  expect: () => [
    SimulatorState(
      pages: [
        [const AiSurfaceDisplayMessage('surface-1')]
      ],
      currentPageIndex: 0,
    ),
  ],
);
```

**Catalog item tests** verify that each widget renders correctly from JSON data, without needing an LLM, a network connection, or the rest of the app.

**Prompt tests** verify the prompt builder produces correct output for different onboarding selections.

In the official sample, none of this is possible without rendering the entire `TravelPlannerPage` and intercepting network calls.

## The Comparison

| Concern | Official Sample | VGV Architecture |
|---------|-----------------|------------------|
| AI client setup | Widget's `initState()` | Repository constructor |
| Conversation state | `ValueNotifier` | `SimulatorBloc` with sealed events/states |
| GenUI plumbing | Inline in widget | `SimulatorRepository` |
| Rendering infrastructure | Created in widget | Page creates `Catalog` + `SurfaceController`, injects into repo and view |
| Domain events | Raw `ConversationEvent` | Sealed `SimulatorConversationEvent` |
| Display models | Mixed `ChatMessage` list | Sealed `DisplayMessage` hierarchy |
| Catalog | Inline in page | Separate `catalog/` layer with per-item files |
| System prompt | String literal in widget | Dedicated `PromptBuilder` class |
| Presentation | One `StatefulWidget` | `SimulatorPage` (composition root) + `SimulatorView` (rendering) |
| Bloc purity | N/A | No GenUI imports — only plain Dart types in state |
| Unit testability | Widget tests only | Every layer independently testable |
| Custom widgets | Few, inline | 26 items, each in its own file with tests |

Want to see this architecture in action? Try the [Life Goal Simulator live demo](https://verygood.ventures/events/google-cloud-next-2026/).

## Key Takeaways

**GenUI doesn't need a special architecture.** The LLM is a data source. The GenUI SDK is an API client. The repository pattern wraps it. Bloc manages state. Widgets render. The same principles that make traditional Flutter apps maintainable work here too.

**The repository is the critical abstraction.** GenUI introduces several interacting components — `Conversation`, `A2uiTransportAdapter`, `PromptBuilder`. The repository hides the conversation plumbing behind `startConversation()` and `sendMessage()`. Every dependency is injected: the `ChatModel` (so you can swap LLM providers), the `Catalog` and `SurfaceController` (rendering concerns owned by the presentation layer). When the SDK evolves (and it will), only one file changes.

**Keep SDK types out of your Bloc.** It's tempting to thread `SurfaceHost` through Bloc state so the view can access it. But the Bloc shouldn't know about GenUI's rendering types. Instead, the Page — which is already the composition root — creates the `SurfaceController` and passes it to both the repository and the view directly.

**Domain events create a seam.** Translating GenUI's internal events into your own sealed classes protects the rest of your app from SDK changes and makes the event flow explicit and exhaustive.

**Catalog items deserve their own layer.** In the Life Goal Simulator, each of the 26 financial widgets has a schema, a builder, and behavioral rules. Giving each its own file makes them independently testable and reusable across features.

**Prompt engineering is code.** The Life Goal Simulator's `PromptBuilder` class treats system prompts and initial messages with the same rigor as any other module — separate files, testable functions, version-controlled alongside the catalog they reference.

## Conclusion

The official samples are the right place to learn how GenUI works. This post covered how to architect around it. The two together give you everything you need to take Flutter's GenUI SDK from prototype to production.

---

*The Life Goal Simulator was built by [Very Good Ventures](https://verygood.ventures). Check out the [live demo](https://verygood.ventures/events/google-cloud-next-2026/) and our [engineering practices](https://engineering.verygood.ventures).*
