Quick Start

This guide builds a complete todo list application to introduce the Chassis framework. By implementing each component manually, you'll understand how data flows from the UI through business logic to the repository and back. This foundation prepares you to leverage code generation effectively in production applications. Expect to complete this tutorial in approximately 15 minutes, ending with a working application that demonstrates the core architectural patterns.

Installation

Adding Dependencies

Chassis consists of three core packages that work together. The chassis package provides pure Dart primitives for Commands, Queries, and the Mediator. The chassis_flutter package integrates with Flutter's widget tree through ViewModels and reactive widgets. The chassis_builder package generates boilerplate code from annotations, though we won't use it in this Quick Start.

Add these dependencies to your pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  chassis: ^0.0.1
  chassis_flutter: ^0.0.1
  provider: ^6.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  chassis_builder: ^0.0.1
  build_runner: ^2.4.0

The Todo List Example

Creating the Repository Interface

In the simplest terms, a repository defines what data operations are possible without specifying how they're implemented. This abstraction enables testing and allows you to swap implementations—in-memory for development, Firebase for production, or a mock for tests—without changing business logic or UI code.

First, create the data model in lib/data/todo.dart:

Then create lib/data/todo_repository.dart:

The interface ITodoRepository declares what operations are available (watchTodos, addTodo, toggleTodo) without specifying how they work. The implementation InMemoryTodoRepository uses a StreamController to broadcast todo list changes reactively. The Todo model uses the copyWith pattern to ensure immutability—rather than modifying todos in place, we create new instances with updated values. By programming to the interface, your application can work with any implementation—swap InMemoryTodoRepository for a Firebase version without changing your business logic.

Writing Business Logic

Now that you've defined your data layer, it's time to implement the business logic—the code that decides what happens when users interact with your application. This is where you define the actual behavior: what to do when a user adds a todo, what validation to apply before persisting, or how to transform data before presenting it to the UI.

Business logic should be independent of Flutter widgets, making it fast to test and easy to reason about. By isolating this code from UI concerns, you can verify behavior without rendering widgets, navigate complex scenarios with simple unit tests, and refactor with confidence knowing tests will catch breaking changes.

Commands and queries

Chassis organizes business logic using Command-Query Responsibility Segregation (CQRS), distinguishing between operations that read data (Queries) and operations that change state (Commands). This separation clarifies intent—when you see a Query, you know it's safe to call repeatedly without side effects. When you see a Command, you know state will change.

The benefits become evident as applications grow:

  • Queries return data without side effects, making them safe to cache, retry, or call in parallel

  • Commands represent intent to change state, making it clear where mutations occur and enabling audit logging or undo functionality

This separation allows different optimization strategies: aggressive caching for Queries, transaction handling for Commands

See Core Architecture for deeper exploration of CQRS principles.

Implementing Handlers

In Chassis, business logic lives in stateless handlers classes that receive messages from the Mediator and coordinate with repositories to fulfill requests. Each handler focuses on a single responsibility: receive a message, execute business logic, call repositories as needed, and return results.

Messages are pure data containers that carry intent. The WatchTodosQuery message says "I want to watch the todo list," while the AddTodoCommand says "I want to add a todo." The actual implementation lives in the corresponding handler.

Note: In real-world applications, many Handlers are only wrappers around repository methods. The @generateHandler annotation allows to generates them automatically. We write them manually here to understand what the code generation produces. See Code Generation to learn how to eliminate this boilerplate.

Create lib/domain/todo_handlers.dart:

Notice the dependency injection pattern—each handler receives its repository through the constructor. This ensures testability and loose coupling, enabling testing handlers in isolation. The commands carry the data they need—AddTodoCommand has a title, and ToggleTodoCommand has an id to identify which todo to toggle.

While this todo example shows simple pass-through handlers, real applications contain validation, transformation, and coordination logic here. You might validate that the title isn't empty before persisting, combine data from multiple repositories, or apply business rules before returning results. This is where business complexity lives—not scattered across widgets, but concentrated in testable, framework-independent handlers.

The handler's logic is pure business code with no Flutter dependencies, making it fast and easy to test. See Business Logic for detailed testing strategies and examples of more complex handler implementations.

Accessing business logic

Your UI needs a single entry point to execute business logic—this is the Mediator. Instead of ViewModels depending directly on multiple repositories or handlers, they depend only on the Mediator. This centralization provides several benefits:

  • Single dependency for UI: ViewModels only need the Mediator, simplifying their constructor signatures

  • Dependency injection: The Mediator wires handlers to their dependencies at startup, managing the object graph

  • Extensibility: Middleware can intercept Commands and Queries for logging, validation, or caching without changing handlers

  • Discoverability: Type-safe extension methods make all available operations autocomplete-friendly

In production applications, chassis_builder automatically generates the Mediator implementation, handler registration, and type-safe extension methods by scanning for @chassisHandler annotations. We'll create this manually here to understand what gets generated.

Create lib/app/app_mediator.dart:

The extension methods transform generic message dispatching into a clean, type-safe API. Instead of mediator.run(AddTodoCommand(title: title)), you call mediator.addTodo(title). Your IDE autocompletes available operations, making the application's capabilities immediately discoverable.

This entire file —the Mediator class, handler registrations, and extension methods- is automatically generated from your @chassisHandler annotations. See Code Generation to learn how to eliminate this boilerplate entirely.

Preparing data for the view

The ViewModel transforms domain data into UI-ready state and handles user interactions by dispatching commands. It sits between the Mediator and the widget tree, translating business operations into state changes that widgets can observe.

Create lib/presentation/todo_view_model.dart:

The ViewModel demonstrates Chassis's complete data flow cycle. The watch() call establishes a subscription to the repository's todo stream through the Mediator. When the repository emits a new list, the ViewModel receives it and wraps it in Async<T>, then updates its state. The UI automatically rebuilds to reflect the new todo list.

When a user adds a todo, the flow is:

  1. UI calls viewModel.addTodo(title)

  2. ViewModel calls mediator.addTodo(title) which dispatches the command

  3. Mediator routes to AddTodoCommandHandler

  4. Handler calls repository.addTodo(title)

  5. Repository emits new todo list through its stream

  6. ViewModel's watch callback receives the update

  7. UI rebuilds with new todo list

State immutability ensures predictable behavior — the copyWith pattern creates new state objects rather than mutating existing ones. The Async<List<Todo>> wrapper handles loading, data, and error states automatically, eliminating manual state checking in the UI. Events provide a channel for one-time occurrences like clearing the input field or showing a snackbar, separate from persistent state.

Building the UI

The UI layer observes state changes and dispatches user interactions to the ViewModel. The AsyncBuilder widget automatically renders appropriate UI based on whether data is loading, available, or errored. The ConsumerMixin handles event subscriptions, ensuring proper cleanup when widgets dispose.

Create lib/presentation/todo_screen.dart:

The AsyncBuilder widget eliminates manual state checking. It automatically renders the appropriate UI based on whether data is loading, available, or errored, simplifying your build methods significantly. The ConsumerMixin handles event subscriptions and ensures proper cleanup when the widget disposes, preventing memory leaks. The TextEditingController is cleared automatically when a todo is added via the TodoAddedEvent, demonstrating how events coordinate UI actions separate from state updates.

Putting It All Together

The main entry point wires together your dependency tree from the bottom up: Repository → Mediator → ViewModel → UI. This composition happens once at startup, creating the object graph that your application uses throughout its lifecycle.

Create lib/main.dart:

The dependency tree flows naturally — repositories have no dependencies, the Mediator depends on repositories, ViewModels depend on the Mediator, and widgets depend on ViewModels. This unidirectional dependency graph makes the application easy to reason about and test.

What You Just Built

You've created a complete Chassis application with clear separation of concerns. The architecture flows naturally through distinct layers:

  • Repository layer: Defines data operations through interfaces, implemented by concrete classes

  • Handler layer: Coordinates business logic, translating messages into repository calls

  • Mediator layer: Routes messages to handlers, provides type-safe API through extensions

  • ViewModel layer: Manages UI state reactively, wrapping async operations in Async<T>

  • UI layer: Observes state and dispatches user actions, automatically handling loading/error states

The key benefits of this architecture:

  • Testability: Each layer can be tested in isolation with mocks

  • Discoverability: Extension methods make available operations autocomplete-friendly

  • Maintainability: Business logic lives in handlers, not spread across widgets

  • Scalability: Adding features follows the same pattern, maintaining consistency

Your application's capabilities are explicitly declared — to understand what the todo list can do, examine todo_handlers.dartarrow-up-right to see WatchTodosQuery, AddTodoCommand, and ToggleTodoCommand. This explicit catalog of operations helps new team members quickly understand the system.

Next Steps

This manual approach exposed the framework's internals, showing exactly how messages flow from UI to repository and back. You now understand what the @generateQueryHandler and @generateCommandHandler annotations automate. In production applications, code generation handles the repetitive handler creation and Mediator wiring, reducing this example to approximately 50 lines of code.

For deeper understanding of the architectural principles guiding these patterns, explore Core Architecture. To learn about testing strategies and when to implement handlers manually versus generating them, see Business Logic. To eliminate the boilerplate you just wrote, discover Code Generation. To learn advanced UI patterns like anti-flickering and event handling, proceed to UI Integration.

Last updated