Streamline은 CQRS 및 매개자 패턴을 구현하여 애플리케이션 아키텍처를 단순화하고 명확한 관심사 분리 및 확장 가능한 상태 관리를 촉진하는 Flutter/Dart 패키지입니다. 중앙 집중식 매개자를 통해 명령과 쿼리를 처리하는 것을 간소화하여 뼈대 코드를 줄이고 테스트 가능성을 향상시킵니다. 구조적이고 유지보수 가능한 코드베이스가 필요한 중규모에서 대규모의 Flutter 앱에 이상적입니다. 이 라이브러리는 Flutter의 반응형 에코시스템과 원활하게 통합되며 모든 주요 플랫폼을 지원합니다.
사용 사례
확장 가능한 Flutter 애플리케이션 빌드
CQRS를 활용한 클린 아키텍처 구현
명령 및 쿼리 처리 간소화
상태 관리에서 반복 코드 줄이기
더 나은 테스트 가능성과 유지보수성 제공
주요 기능
CQRS 패턴 지원
중재자 패턴 구현
내장된 종속성 주입
크로스 플랫폼 호환성
최소한의 보일러플레이트
기존 플러터 프로젝트와의 간편한 통합
적합한 대상
클린 아키텍처를 사용하는 개발자
복잡한 Flutter 앱을 개발하는 팀
구조화된 상태 관리가 필요한 프로젝트
높은 테스트 가능성 요구 앱
코드 중복을 줄이고자 하는 개발자
고려 사항
CQRS 및 Mediator 패턴에 대한 이해 필요
작은 프로젝트에 복잡성을 추가할 수 있음
새로운 사용자를 위한 학습 곡선
기본 예제 외에 문서가 제한적
명확한 명령/질의 경계를 가진 앱에 가장 적합
원본 README
연결된 소스 저장소의 README 원문 스냅샷입니다. 최신 내용은 저장소에서 확인하세요.
Streamline - Mediator Pattern for Flutter
A simple yet powerful library to implement the Mediator pattern in your Flutter applications, promoting clean architecture and separation of concerns.
The Problem: Tangled Code
As Flutter applications grow, managing the flow of data and actions can become complex. Views might directly call data sources, business logic gets mixed into UI code, and components become tightly coupled. This makes the app:
Hard to understand: Where does data come from? What happens when I click this button?
Difficult to test: How do you test business logic without involving the UI or real network calls?
Brittle to change: Modifying one part often breaks another unexpectedly.
The Solution: Separation and Mediation
A robust approach is to separate your application into distinct layers:
Views (UI): What the user sees and interacts with (Your Flutter Widgets). Responsible only for displaying data and capturing user input.
Business Logic: The core rules, processes, and decisions of your application. This is where the real work happens, in the form of queries, commands and events:
Questions your app needs to answer
Commands your app needs to execute
Events that happen in your app
I/O (Data & Services): How your application interacts with the outside world (Databases, APIs, Device Sensors, etc.).
This library helps you manage the Business Logic layer and the communication between different parts of your app using the Mediator pattern.
Core Concepts
1. Questions (Queries)
These are requests for information your app needs. For example:
"Who is the currently logged-in user?"
"What items are in the shopping cart?"
"What is the total order amount?"
2. Commands
These are actions your app needs to perform. For example:
"Sign in with Google"
"Add product to cart"
"Place order"
3. Domain Events
These are notifications about important things that happened in your app. For example:
"User signed in"
"Product added to cart"
"Order placed"
4. Messages
Messages are simpler events that you can use to communcate between different parts of your application, without the need to use events, pipeline behaviours, etc. They differ from events in that they don't have handlers, observers, pipelines and they always trigger the handlers, no matter if the previous message is the same. Also, events are retriggered when new subscribers are added and you can get the last emitted event. Messages are volatile and are not stored anywhere (so new listeners do not receive previous messages).
Messages are useful for communicating between different widgets, for example, when you have an AppBar in a parent widget and you want to relay the button taps to a child widget (or vice-versa).
How does it work?
Instead of components calling each other directly, they communicate through a central Mediator. You structure your business logic around three core concepts:
Queries: Asking for information.
Think of them as specific questions your UI (or other parts of your logic) might ask.
Commands trigger actions and might change the application's state. They may or may not return a direct result.
Domain Events: Announcing that something significant happened.
After a Command successfully executes, it might publish an Event.
Other parts of your application can listen for these events and react accordingly, without knowing who originally caused the event. This creates loose coupling.
Especially for Streamline, those events are the triggers for UI rebuild (you use QueryBuilder<IQuery> to ask a question and render some UI with the response and you can listen to some event (with filters, if you need) to determine when that question will be asked again and the UI rebuilt).
The Mediator's Role
The Mediator receives Queries and Commands, finds the appropriate Handler (the piece of code responsible for processing that specific Query or Command), executes it, and returns the result (for Queries) or manages the flow (for Commands, potentially publishing Events).
Usually, your handlers are always the same, having no code other than the business logic. Anything that you need to use, such as databases, APIs, etc. can be injected in the handler constructor as dependencies, using interfaces. This makes it easy to test your handlers in isolation, without having to mock or stub any external dependencies.
Why Use This Library?
Clear Separation: Enforces a clean separation between UI, business logic, and data fetching/persistence.
Improved Testability: Handlers are typically small, focused classes that can be easily unit tested in isolation without needing Flutter widgets or external services.
Enhanced Maintainability: Logic for specific features is encapsulated within specific Handlers. Adding or changing features often involves adding/modifying specific Handlers, reducing side effects.
Scalability: Easier to manage growing complexity as new features often mean adding new Queries, Commands, and Handlers without drastically altering existing ones.
Decoupling: Components communicate via the Mediator and Events, reducing direct dependencies. Your UI doesn't need to know how user data is fetched, only that it needs to ask a question AvailableProductsQuery.
Getting Started
Add Dependency: Add the library to your pubspec.yaml:
dependencies:streamline:^latest_version# check the latest version on pub.dev
Configure Mediator:
runApp(
// The mediator config is where you bind all your commands, queries,// aggregators and event handlers with the appropriate handlers.//// Notice that you can do that manually, using, for instance the// [Mediator.$queryHandler] method or any other method in [Mediator].MediatorConfig(
// This method will be called after all handlers are registered
onInitialize: () async => debugPrint("Mediator initialized!"),
// If this is null, a [SizedBox.shrink] is used:
onInitializeWaitingBuilder: (context) => constText("Initializing..."),
// Here we register all our request handlers. Notice that we are creating// our ToDo repository and passing it here (so, basically, this is a// singleton dependency being injected to whomever needs it). If you wish,// you can use a dependency injection library like GetIt for this purpose.
queryHandlers: {
GetToDosQuery: () => GetToDosQueryHandler(repository: repo),
},
// Here we register all our command handlers.
commandHandlers: {
SaveToDoCommand: () => SaveToDoCommandHandler(repository: repo),
DeleteToDoCommand: () => DeleteToDoCommandHandler(repository: repo),
},
// From here on, you can use any Streamline widgets
child: constToDoApp(),
),
);
Start asking questions:
/// This class represents a message of type *Query*. It express the desire/// to get something, in this case, a list of to-dos. That's why it/// implements the [IQuery<TResponse>] interface, where the expected/// response is a [List<ToDo>].@MappableClass()
finalclassGetToDosQuerywithGetToDosQueryMappableimplementsIQuery<GetToDosResponse> {
constGetToDosQuery({requiredthis.showOnlyCompleted});
/// If true, only completed to-dos are returnedfinalbool showOnlyCompleted;
staticconst fromMap = GetToDosQueryMapper.fromMap;
staticconst fromJson = GetToDosQueryMapper.fromJson;
}
/// This is the code that will actually be executed when a [GetToDosQuery]/// query is requested. You can use depedency injection here if you wish, so/// the code that actually accesses a database or a remote API returns the list/// of to-dos.////// Another approach you can take is: all the common/// code that does not touch any external dependency, I/O or package, we write/// in an abstract class with the methods that requires those dependencies as/// an abstract method that must be implemented by the concrete class. Then,/// in the registration, we choose the concrete class as the handler of this/// type of request.////// The advantage of this approach is that you can have code here that is/// testable (such as validations, business rules, etc.), that doesn't need to/// be reimplemented by the concrete class and that class can have anything you/// have available here, without the need to know anything else about your/// application. Is the approach where you implement the least possible code/// and boilerplate, gives you the most flexibility and testability and allows/// you to use this library as intended: a lego library where you can plugin/// packages (or mocks for testing) with the least amount of code possible.////// The [IQueryHandler<TQuery, TResponse>] interface means a class that can/// handle the query TQuery which returns TResponse. Only one query handler/// can exist in the app (if you need more than one, then you must use the/// [IAggregator<TResponse>] pattern instead).finalclassGetToDosQueryHandlerimplementsIQueryHandler<GetToDosQuery, GetToDosResponse> {
constGetToDosQueryHandler({requiredIToDosRepository repository})
: _repository = repository;
/// We will inject this data repository herefinalIToDosRepository _repository;
/// Whenever someone requests a [GetToDosQuery] query, this method will be/// called.@overrideFuture<GetToDosResponse> handle(GetToDosQuery query) async {
// The only business logic here is to translate whatever our repository// returns. It could be a single-liner, as this example, and this is fine,// as, sometimes, you don't actually need any kind of business logic.//// Since this is a safe class, no exceptions are allowed here (meaning:// the [fetchToDos] implementation will be considered unsafe and you must// use a try/catch block there to ensure it returns a valid response always,// even if it is a [ExceptionGetToDosResponse])returnawait _repository.getToDos(query.showOnlyCompleted);
}
}
Usage Examples
1. Define a Query and its Handler:
// Define the Query (the question)classGetTodosQueryextendsQuery<List<Todo>> {
// Optional: Add parameters if needed, e.g., filter criteria
}
// Define the Handler (the logic to answer the question)classGetTodosQueryHandlerextendsQueryHandler<GetTodosQuery, List<Todo>> {
finalITodoRepository _repository; // Inject dependenciesGetTodosQueryHandler(this._repository);
@overrideFuture<List<Todo>> handle(GetTodosQuery query) async {
// Logic to fetch todos from the repositoryreturnawait _repository.getAllTodos();
}
}
Check the example To Do App
Available here: https://github.com/JCKodel/streamline/tree/main/example
⚠️ Important
This library *requires" queries, commands and events to use https://pub.dev/packages/dart_mappable. Dart Mappable will add value equality to your classes (required to check, for instance, if an event is the same as the previous one, so no changes are rebuilt). Dart Mappable was chosen because on top of it you'll get a copyWith method, serialization/deserialization and a nice toString() method. Also, it's not unflexible like freezed and does not require a list of properties that you might forget, like equatable.
Other than that, the only other dependency is rxdart, which is used to manage streams and events.
License
This package is licensed under the GNU AGPL v3 license.
Important licensing notes:
👉 You can freely use this package in your applications (commercial or non-commercial) without having to license your entire application under AGPL 👈
The AGPL license only applies if you modify THIS package's source code
If you modify this package's source code and distribute it (either directly or as part of a network service), you must make your modifications available under AGPL v3