Business Logic

This guide explores the Application layer—where your business logic lives. While Chassis is able to automates 90% of standard CRUD operations, understanding manual implementation is essential for the complex scenarios that inevitably arise in production applications. By the end, you'll know how to write handlers for sophisticated workflows, test them in complete isolation, and compose them to handle intricate business requirements. This knowledge forms the foundation for making informed decisions about when to leverage automation and when to implement logic by hand.

Anatomy of Messages

Commands

Commands represent an intent to change application state. They are immutable data structures carrying the parameters needed to perform an action, named after business operations rather than technical implementations. A command should describe what you want to accomplish—UpdateUserEmail, ProcessPayment, SubmitOrder—not how the system will accomplish it.

Commands must be immutable to prevent accidental mutations during handling. Use final fields and const constructors wherever possible. The type parameter R in Command<R> specifies what the command returns—use void for operations that produce no result, or a concrete type for operations that return created or updated entities.

// Simple command (void return)
class LogoutCommand implements Command<void> {
  const LogoutCommand();
}

// Command with parameters and return value
class CreateOrderCommand implements Command<Order> {
  const CreateOrderCommand({
    required this.userId,
    required this.items,
    required this.shippingAddress,
  });

  final String userId;
  final List<OrderItem> items;
  final Address shippingAddress;
}

// Command with validation
class UpdateUserEmailCommand implements Command<void> {
  const UpdateUserEmailCommand({
    required this.userId,
    required this.newEmail,
  }) : assert(newEmail.length > 0, 'Email cannot be empty');

  final String userId;
  final String newEmail;
}

Commands should be self-contained, carrying all the information needed to execute the operation. Avoid holding references to repositories or services—those belong in handlers. Validation in constructors provides early failure detection, catching invalid states before they reach handlers. The command definition lives in lib/src/mediator/command.dart:19-22arrow-up-right.

Queries

Queries retrieve data without causing side effects, adhering to the Command-Query Separation principle discussed in Core Architecture. Chassis provides two query types based on consumption pattern. Use ReadQuery for one-time data fetches and WatchQuery for reactive data streams.

In modern reactive Flutter applications, most data should come from WatchQuery streams to keep the UI automatically synchronized with data changes. However, ReadQuery remains useful for specific scenarios: non-interactive operations like report generation or data exports that produce files, one-time validation checks that don't affect displayed data, or initial bootstrapping operations that run before the UI renders. If the data might change and the UI should reflect those changes, prefer WatchQuery.

WatchQuery handles reactive data streams that update over time. This should be your default choice for any data displayed in the UI. User profiles, product lists, shopping carts, notification counts, and search results all benefit from automatic updates when underlying data changes. The stream remains active until explicitly cancelled, keeping the UI synchronized with the data layer without manual refresh logic.

The type system enforces correct usage. Attempting to watch a ReadQuery results in a compile error, preventing accidental stream subscriptions for one-time operations. This distinction enables the Mediator to route requests appropriately and avoid common bugs like memory leaks from uncancelled subscriptions. Query definitions are in lib/src/mediator/query.dart:9-39arrow-up-right.

The Handler Contract

Handlers are plain Dart classes receiving and processing commands and queries. They are stateless and testable in complete isolation from the UI and the framework, allowing for pure logic verification with no Flutter dependencies. This is one of the key testability benefits of the Chassis architecture.

CommandHandler Structure

A CommandHandler implements the CommandHandler<C, R> interface, where C is the command type and R is the return type. Handlers receive dependencies via constructor injection, following the Dependency Inversion Principle from the layered architecture. This pattern keeps handlers testable and prevents them from creating their own dependencies.

For simple handlers with a single dependency, the extends syntax with a lambda provides concise implementation. For complex handlers coordinating multiple services, the implements syntax with explicit method implementation offers more control and clarity.

Complex handlers orchestrate multiple services to fulfill a single command, implementing business workflows that span multiple domain boundaries. Error handling occurs at the handler level—throwing exceptions causes the ViewModel to receive an AsyncError state, which the UI can then render appropriately. The handler contract is defined in lib/src/mediator/command.dart:44-78arrow-up-right.

QueryHandler Structure

Query handlers follow the same dependency injection pattern as command handlers, but implement different methods based on their type. ReadHandler implements a read() method returning Future<R>, while WatchHandler implements a watch() method returning Stream<R>. Both patterns enable the same testability benefits through interface dependencies.

ReadHandlers often incorporate caching logic, since queries do not mutate state. Checking a cache before hitting the database or network can dramatically improve performance for frequently accessed data.

The WatchHandler's async generator pattern (async* and yield) provides elegant stream composition. You can emit an initial value immediately, then merge in real-time updates from external sources. This pattern appears frequently in applications consuming WebSockets, Firebase, or other streaming data sources. The query handler contract is defined in lib/src/mediator/query.dart:76-178arrow-up-right.

Dependency Injection

Handlers receive dependencies through constructors, not through service locators or global singletons. This explicit dependency declaration improves testability by making dependencies visible and mockable. It also prevents the hidden coupling that service locators introduce, where a class's dependencies are only discoverable by reading its implementation.

The Mediator construction site becomes your composition root—the single place where you wire together your entire dependency graph. In a real-world application, the AppMediator (alongside others useful extensions) is automatically generated by Chassis thank to the @chassisHandler annotation.

Testing Strategy

Unit Testing Handlers

Handlers are the ideal unit for testing business logic because they are pure Dart classes with no Flutter dependencies. Use mocks for repository interfaces to control test conditions precisely, simulating success cases, error conditions, and edge cases without touching real databases or networks.

Notice the test requires no Flutter TestWidgets or Mediator setup. The handler is tested in complete isolation with only the dependencies it explicitly declares. Mock verification ensures business logic executes in the correct order—inventory check before payment, payment before order creation. This precision is difficult to achieve in end-to-end tests but straightforward in focused unit tests.

Integration Testing with Mediator

Integration tests verify that handlers are correctly registered and messages are routed properly through the Mediator. Use a real Mediator instance with mock repositories to test the wiring between components without involving the UI.

Integration tests catch wiring errors that unit tests miss. They verify that commands route to the correct handlers and that the Mediator's type resolution works as expected. These tests run quickly because they use mocks rather than real infrastructure.

Summary

Manual handler implementation provides complete control over business logic, enabling complex workflows that code generation cannot automate. Commands and Queries express intent through immutable, well-named types. Handlers coordinate dependencies to fulfill those intents, while remaining testable through interface-based dependency injection. The testing strategy isolates handlers for unit tests, verifies wiring with integration tests, and mocks the Mediator for ViewModel tests.

With this foundation in manual implementation, the next section explores how Code Generation automates the 90% of handlers that follow standard patterns, eliminating boilerplate while preserving architectural benefits.

Last updated