Ark MVP
Pure Dart foundations for Model–View–Presenter features with explicit Model composition, presentation-state adaptation, one-time ViewEffects, and a bounded Presenter lifecycle.
Ark MVP does not prescribe a state manager, dependency-injection container, repository implementation, or UI framework. Application composition reads the business objects it already owns and exposes one immutable feature Model. A Presenter converts that Model into presentation-ready ViewState and publishes only the actions and transient effects that belong to the View.
0.1.0-dev.1is a prerelease. The lifecycle model is implemented and tested, but public API details may change before the first stable version.
Why this package exists
MVP is often reduced to a class called Presenter that merely forwards raw
state to a widget. That leaves the View responsible for interpreting business
phases, combining several state managers, formatting values, and deciding which
operations are allowed. The class exists, but the adaptation boundary does not.
Ark MVP gives each layer a narrower responsibility:
- application composition connects existing business objects to a feature;
- Model is one immutable snapshot of everything the feature currently needs;
- Presenter converts that snapshot into presentation-ready values and actions;
- View renders ViewState and delegates user intent to Presenter methods;
- ViewEffect carries a one-time UI request that must not become persistent state.
The API keeps Model out of View without adding runtime type checks: MvpView
receives ViewState and Presenter, never Model. Dart cannot determine whether an
application has placed a technical object inside its own Model or ViewState;
that semantic boundary remains visible and reviewable in the feature types.
The complete flow
Business objects owned by the application
├── one UseCase
├── several UseCases
├── application state manager
└── any other typed business boundary
│
│ read complete immutable snapshot
▼
ModelBinding<Model>
│
│ Model changed
▼
Presenter<Model, ViewState, ViewEffect, Environment>
│
├── builds ViewState
├── exposes feature actions
├── invalidates Presenter-local UI state
└── emits one-time ViewEffects
│
▼
View
The arrows are intentionally one-directional. A View does not read the Model, UseCase, repository, or service. A Presenter does not own the business sources behind its ModelBinding. The composition root keeps those ownership decisions.
Installation
dependencies:
ark_mvp: ^0.1.0-dev.1
import 'package:ark_mvp/ark_mvp.dart';
Core concepts
Model
Ark MVP deliberately has no universal Model base class. The feature defines
an ordinary immutable type whose fields express its actual business inputs and
allowed operations.
final class CounterModel {
const CounterModel({
required this.value,
required this.increment,
});
final int value;
final void Function() increment;
}
This is not a domain entity and not a second state manager. It is a feature snapshot assembled at the presentation boundary. A more complex Model can contain several typed business snapshots and actions:
final class ProfileModel {
const ProfileModel({
required this.profile,
required this.update,
required this.session,
required this.reload,
required this.save,
});
final ProfileSnapshot profile;
final UpdateSnapshot update;
final SessionState session;
final void Function() reload;
final void Function(ProfileDraft draft) save;
}
The package cannot enforce deep immutability in Dart. The binding contract
requires each read() result to remain a stable snapshot after it is returned.
ModelBinding
ModelBinding<M> connects application-owned business state to the MVP runtime:
final ModelBinding<CounterModel> binding = ModelBinding<CounterModel>(
read: () => CounterModel(
value: counter.value,
increment: counter.increment,
),
changes: <Stream<Object?>>[
counter.changes,
],
);
The values emitted by changes are signals, not partial Model updates. After
every signal, the runtime calls read() and obtains a complete, internally
consistent Model snapshot. This makes several heterogeneous streams possible
without teaching Ark MVP what their event types mean.
The binding does not close the streams or the objects captured by read. If an
application created a UseCase, state manager, or subscription, the application
still owns its lifecycle.
Presenter
Presenter is the adapter between Model and View. It receives the complete Model but exposes only presentation-ready ViewState and feature actions.
final class CounterViewState {
const CounterViewState({
required this.formattedValue,
required this.canIncrement,
});
final String formattedValue;
final bool canIncrement;
}
final class CounterPresenter extends Presenter<
CounterModel,
CounterViewState,
CounterEffect,
CounterEnvironment> {
@override
CounterViewState buildViewState(CounterEnvironment environment) {
return CounterViewState(
formattedValue: environment.formatNumber(model.value),
canIncrement: model.value < 100,
);
}
void increment() {
if (model.value >= 100) {
emitEffect(const CounterLimitReached());
return;
}
model.increment();
}
}
The View does not reproduce the value < 100 rule or number formatting. It
receives formattedValue, canIncrement, and the increment() action.
ViewState
ViewState is an application-defined immutable value. It contains durable presentation data for the current frame:
- formatted text;
- visibility and enabled-state flags;
- selected items;
- validation messages;
- loading, empty, content, and error presentation variants;
- other values that can be rebuilt repeatedly without changing meaning.
There is no package base class because different features need different fields. Keeping a concrete type also makes the View compile-time explicit.
ViewEffect
ViewEffect represents a one-time request to the active View:
sealed class CounterEffect {
const CounterEffect();
}
final class CounterLimitReached extends CounterEffect {
const CounterLimitReached();
}
Effects are broadcast without retention or replay. A newly attached View does not repeat an old dialog, navigation request, snackbar, or focus action. If a value must survive rebuilding or reattachment, it belongs in ViewState instead.
Use Never as the effect type when a Presenter has no effects.
Pure Dart host
PresenterSession exposes the framework-independent lifecycle. Most Flutter
applications use MvpView from ark_mvp_flutter; another UI adapter or a pure
Dart program can drive the same Presenter directly:
final CounterPresenter presenter = CounterPresenter();
final PresenterSession<
CounterModel,
CounterViewState,
CounterEffect,
CounterEnvironment
> session = PresenterSession(
presenter: presenter,
initialModel: binding.read(),
);
final effectSubscription = session.effects.listen(handleEffect);
final invalidationSubscription = session.invalidations.listen((_) {
render(session.buildViewState(environment));
});
session.start();
render(session.buildViewState(environment));
final modelSubscriptions = binding.changes.map((source) {
return source.listen((_) {
session.updateModel(binding.read());
render(session.buildViewState(environment));
});
}).toList();
// Later, after the host stops presenting the feature:
for (final subscription in modelSubscriptions) {
await subscription.cancel();
}
await effectSubscription.cancel();
await invalidationSubscription.cancel();
await session.close();
Subscribe before start(): onModelAttached is allowed to invalidate the View
or emit an effect.
Presenter lifecycle
One Presenter instance belongs to exactly one session:
- the runtime creates a Presenter and attaches the initial Model;
- output listeners subscribe;
start()invokesonModelAttachedonce;- Model signals produce
updateModel(previous, current)transitions; - the host asks
buildViewState(environment)whenever presentation changes; close()invokesclosePresenter, closes output channels, and detaches the Presenter.
A closed Presenter cannot be reused. Create a new instance for a new feature or binding lifecycle. This prevents stale local state and subscriptions from crossing feature boundaries.
Presenter-local state
Small presentation-only state can live in Presenter: a temporary expanded
section, a selected local tab, or a draft-display mode. After changing it, call
invalidateView() so the host rebuilds ViewState.
Do not move business truth into Presenter. Data that must survive screen replacement, participate in domain decisions, or be shared with another feature belongs in a business state manager and therefore in Model.
What Ark MVP does not do
Ark MVP intentionally does not provide:
- dependency injection or a service locator;
- repositories, DataSources, or transport adapters;
- a business-state implementation;
- a universal Intent hierarchy;
- automatic ownership of UseCases or state managers;
- navigation;
- persistence;
- reflection or code generation;
- a global Model registry.
These omissions keep the MVP boundary usable with ordinary constructors, Ark DI, another DI solution, UseCase Forge, or an application-specific manager.
Examples and detailed guides
- Basic pure Dart Presenter
- Application state manager composition
- Architecture and responsibilities
- Model composition
- Lifecycle, ViewState, and ViewEffect
Flutter hosting and UseCase Forge composition are documented in
ark_mvp_flutter.
Libraries
- ark_mvp
- Pure Dart foundations for Model-View-Presenter application features.