fmodel 0.1.0-dev.1
fmodel: ^0.1.0-dev.1 copied to clipboard
Functional domain modeling primitives for Dart.
f(model) - Functional Domain Modeling for Dart #
When you are developing an information system to automate the activities of the business, you are modeling the business. The abstractions that you design, the behaviors that you implement, and the UI interactions that you build all reflect the business. Together, they constitute the model of the domain.
IOR<Library, Inspiration> #
This project can be used as a Dart library, or as an inspiration, or both. It provides just enough tactical Domain-Driven Design patterns, optimized for Event Sourcing and CQRS.
- The
domainlibrary is isolated from the application layer and API-related concerns. It represents a pure declaration of program logic with no runtime dependencies beyond Dart. - The
applicationlibrary orchestrates the execution of the logic by loading state, executingdomaincomponents, and storing new state. It is currently experimental while the Dart port is aligned with the Kotlin semantics and the TypeScript public API shape.
The library is non-intrusive. You can use only the domain primitives and model
the orchestration yourself, or use the application layer when its repository
contracts match your needs.

Table of Contents #
- f(model) - Functional Domain Modeling for Dart
IOR<Library, Inspiration>- Dart
- Dart platforms
- Abstraction and generalization
decide: (C, S) -> Stream<E>evolve: (S, E) -> S- Event-sourced or State-stored systems
- Decider
- View
- Saga
- Metadata, locking, and deduplication
- Migration notes
- Public API map
- Scope notes
- Release status
- Start using the library
- Examples
- FModel in other languages
- References and further reading
- Credits
Dart #
Dart has object-oriented and functional constructs. You can use it in both OO and FP styles, or mix elements of the two. With first-class support for function types, lambdas, records, streams, and pattern matching, Dart is a good fit for functional domain modeling.
This port uses:
Stream<T>where the Kotlin reference usesFlow<T>.- Dart records, for example
(State, Version), where Kotlin usesPair. - Constructor-like top-level functions such as
createEventSourcingAggregatewhere Kotlin uses anonymous objects with interface delegation. - Dart extensions for optional domain composition helpers, not for core application behavior.
Dart platforms #
Dart can target the Dart VM, native executables, and JavaScript/Web. The domain model in this package is platform-neutral. The application layer depends on interfaces for repositories and publishers, so storage, messaging, and transport choices remain outside the core package.
Abstraction and generalization #
Abstractions hide irrelevant details and use names to reference objects. They emphasize what an object is or does rather than how it is represented or how it works.
Generalization reduces complexity by replacing multiple entities that perform similar functions with a single construct.
Abstraction and generalization are often used together. Abstracts are generalized through parameterization to provide more utility.
decide: (C, S) -> Stream<E> #
At a higher level of abstraction, any information system is responsible for
handling intent (Command) and, based on the current State, producing new
facts (Events):
- given the current
State/Son the input, - when
Command/Cis handled on the input, - expect a
Streamof newEvents/Eto be emitted on the output.
evolve: (S, E) -> S #
The new state is always evolved out of the current state S and the current
event E:
- given the current
State/Son the input, - when
Event/Eis handled on the input, - expect new
State/Sto be produced on the output.
Event-sourced or State-stored systems #
- State-stored systems store only the current state by overwriting the previous state in storage.
- Event-sourced systems store facts in immutable storage by appending events.
A statement: #
Both types of systems can be designed by using only these two functions and three generic parameters:
decide: (C, S) -> Stream<E>evolve: (S, E) -> S

There is more to it. You can switch from one system type to another, or have both flavors included within your system landscape.
A proof
We can fold or recreate the new state out of a stream of events by using the
evolve function (S, E) -> S and providing an initialState of type S as a
starting point.
events.fold<S>(initialState, evolve): Future<S>
Essentially, this fold maps a stream of events to the state:
(Stream<E>) -> Future<S>
We can now use this function to:
- contra-map our
decidefunction,(C, S) -> Stream<E>, overSto(C, Stream<E>) -> Stream<E>. This is an event-sourced system. - map our
decidefunction,(C, S) -> Stream<E>, overEto(C, S) -> Future<S>. This is a state-stored system.
Two functions are wrapped in a datatype class, generalized with three generic parameters:
final class Decider<C, S, E> implements DeciderContract<C, S, E> {
const Decider({
required this.decide,
required this.evolve,
required this.initialState,
});
@override
final Stream<E> Function(C command, S state) decide;
@override
final S Function(S state, E event) evolve;
@override
final S initialState;
}
Decider is the most important datatype, but it is not the only one. There are
others:

Decider #
Decider represents the main decision-making algorithm. It belongs to the
Domain layer. It has three generic parameters C, S, and E, representing
the types of values that Decider may contain or use.
Decider is a pure domain component.
C- CommandS- StateE- Event
final class Decider<C, S, E> implements DeciderContract<C, S, E> {
const Decider({
required this.decide,
required this.evolve,
required this.initialState,
});
@override
final Decide<C, S, E> decide;
@override
final Evolve<S, E> evolve;
@override
final S initialState;
}
Additionally, initialState is introduced to gain more control over the initial
state of the decider. Decider implements DeciderContract to communicate the
contract.
Example
sealed class RestaurantOrderCommand {
const RestaurantOrderCommand();
}
final class CreateRestaurantOrderCommand extends RestaurantOrderCommand {
const CreateRestaurantOrderCommand(this.identifier, this.lineItems);
final String identifier;
final List<String> lineItems;
}
final class MarkRestaurantOrderAsPreparedCommand
extends RestaurantOrderCommand {
const MarkRestaurantOrderAsPreparedCommand(this.identifier);
final String identifier;
}
sealed class RestaurantOrderEvent {
const RestaurantOrderEvent();
}
final class RestaurantOrderCreatedEvent extends RestaurantOrderEvent {
const RestaurantOrderCreatedEvent(this.identifier, this.lineItems);
final String identifier;
final List<String> lineItems;
}
final class RestaurantOrderPreparedEvent extends RestaurantOrderEvent {
const RestaurantOrderPreparedEvent(this.identifier);
final String identifier;
}
final class RestaurantOrderRejectedEvent extends RestaurantOrderEvent {
const RestaurantOrderRejectedEvent(this.identifier, this.reason);
final String identifier;
final String reason;
}
enum RestaurantOrderStatus { created, prepared }
final class RestaurantOrder {
const RestaurantOrder(this.identifier, this.status, this.lineItems);
final String identifier;
final RestaurantOrderStatus status;
final List<String> lineItems;
RestaurantOrder copyWith({RestaurantOrderStatus? status}) {
return RestaurantOrder(identifier, status ?? this.status, lineItems);
}
}
Decider<RestaurantOrderCommand, RestaurantOrder?, RestaurantOrderEvent>
restaurantOrderDecider() {
return Decider(
initialState: null,
decide: (command, state) async* {
switch (command) {
case CreateRestaurantOrderCommand():
if (state == null) {
yield RestaurantOrderCreatedEvent(
command.identifier,
command.lineItems,
);
} else {
yield RestaurantOrderRejectedEvent(
command.identifier,
'Restaurant order already exists.',
);
}
case MarkRestaurantOrderAsPreparedCommand():
if (state?.status == RestaurantOrderStatus.created) {
yield RestaurantOrderPreparedEvent(command.identifier);
} else {
yield RestaurantOrderRejectedEvent(
command.identifier,
'Restaurant order does not exist or is not in created state.',
);
}
}
},
evolve: (state, event) {
return switch (event) {
RestaurantOrderCreatedEvent() => RestaurantOrder(
event.identifier,
RestaurantOrderStatus.created,
event.lineItems,
),
RestaurantOrderPreparedEvent() => state?.copyWith(
status: RestaurantOrderStatus.prepared,
),
RestaurantOrderRejectedEvent() => state,
};
},
);
}

Decider extensions and functions #
Contravariant
decider.mapLeftOnCommand<Cn>((Cn command) -> C): Decider<Cn, S, E>
Profunctor (Contravariant and Covariant)
decider.dimapOnEvent<En>(fl: (En) -> E, fr: (E) -> En): Decider<C, S, En>decider.dimapOnState<Sn>(fl: (Sn) -> S, fr: (S) -> Sn): Decider<C, Sn, E>
Commutative Monoid
decider.combine(other): Decider<CSuper, (S, S2), ESuper>decider.combineViaMerge(other, ...): Decider<CSuper, SMerged, ESuper>
A monoid is a type together with a binary operation (
combine) over that type, satisfying associativity and having an identity or empty element. Associativity facilitates parallelization by giving us the freedom to break problems into chunks that can be computed in parallel.
combineis also commutative for independent deciders. This means that the order in which deciders are combined does not affect the final state shape when commands and events are routed to their matching component.
We can now construct event-sourcing and state-storing aggregates by using the
same Decider.
Event-sourcing aggregate #
EventSourcingAggregate uses or delegates to a Decider to handle commands
and produce events. It belongs to the Application layer. To handle a command,
the aggregate fetches the current event stream via EventRepository.fetchEvents,
delegates the command to the decider, and stores the new events via
EventRepository.save.

EventSourcingAggregate extends DeciderContract and EventRepository,
clearly communicating that it is composed out of these two behaviors.
Dart does not have Kotlin-style interface delegation syntax. This port uses private delegated implementations behind constructor-like functions:
final aggregate = createEventSourcingAggregate<
RestaurantOrderCommand,
RestaurantOrder?,
RestaurantOrderEvent,
NoMetadata,
NoMetadata
>(
decider: restaurantOrderDecider(),
eventRepository: restaurantOrderEventRepository,
);
Example
typedef RestaurantOrderAggregate = EventSourcingAggregate<
RestaurantOrderCommand,
RestaurantOrder?,
RestaurantOrderEvent,
NoMetadata,
NoMetadata
>;
RestaurantOrderAggregate restaurantOrderAggregate({
required DeciderContract<
RestaurantOrderCommand,
RestaurantOrder?,
RestaurantOrderEvent
> decider,
required EventRepository<
RestaurantOrderCommand,
RestaurantOrderEvent,
NoMetadata,
NoMetadata
> eventRepository,
}) {
return createEventSourcingAggregate(
decider: decider,
eventRepository: eventRepository,
);
}
State-stored aggregate #
StateStoredAggregate uses or delegates to a Decider to handle commands and
produce new state. It belongs to the Application layer. To handle a command,
the aggregate fetches current state via StateRepository.fetchState, delegates
the command to the decider, and stores the new state via StateRepository.save.

StateStoredAggregate extends StateComputation and StateRepository, clearly
communicating that it is composed out of these two behaviors.
final aggregate = createStateStoredAggregate<
RestaurantOrderCommand,
RestaurantOrder?,
RestaurantOrderEvent,
NoMetadata,
NoMetadata
>(
decider: restaurantOrderDecider(),
stateRepository: restaurantOrderStateRepository,
);
Example
typedef RestaurantOrderStateStoredAggregate = StateStoredAggregate<
RestaurantOrderCommand,
RestaurantOrder?,
RestaurantOrderEvent,
NoMetadata,
NoMetadata
>;
RestaurantOrderStateStoredAggregate restaurantOrderStateStoredAggregate({
required DeciderContract<
RestaurantOrderCommand,
RestaurantOrder?,
RestaurantOrderEvent
> decider,
required StateRepository<
RestaurantOrderCommand,
RestaurantOrder?,
NoMetadata,
NoMetadata
> stateRepository,
}) {
return createStateStoredAggregate(
decider: decider,
stateRepository: stateRepository,
);
}
The logic is orchestrated on the application layer. The components and functions are composed in different ways to support a variety of requirements.

View #
View is a datatype that represents the event-handling algorithm responsible
for translating events into denormalized state that is more adequate for
querying. It belongs to the Domain layer. It is usually used to create the
query side of CQRS.
It has two generic parameters, S and E, representing the types of values
that View may contain or use.
View is a pure domain component.
S- StateE- Event
final class View<S, E> implements ViewContract<S, E> {
const View({required this.initialState, required this.evolve});
@override
final Evolve<S, E> evolve;
@override
final S initialState;
}
View implements ViewContract to communicate the contract.
Example
final class RestaurantOrderViewState {
const RestaurantOrderViewState(this.identifier, this.status, this.lineItems);
final String identifier;
final RestaurantOrderStatus status;
final List<String> lineItems;
RestaurantOrderViewState copyWith({RestaurantOrderStatus? status}) {
return RestaurantOrderViewState(
identifier,
status ?? this.status,
lineItems,
);
}
}
View<RestaurantOrderViewState?, RestaurantOrderEvent> restaurantOrderView() {
return View(
initialState: null,
evolve: (state, event) {
return switch (event) {
RestaurantOrderCreatedEvent() => RestaurantOrderViewState(
event.identifier,
RestaurantOrderStatus.created,
event.lineItems,
),
RestaurantOrderPreparedEvent() => state?.copyWith(
status: RestaurantOrderStatus.prepared,
),
RestaurantOrderRejectedEvent() => state,
};
},
);
}

View extensions and functions #
Contravariant
view.mapLeftOnEvent<En>((En event) -> E): View<S, En>
Profunctor (Contravariant and Covariant)
view.dimapOnState<Sn>(fr: (Sn) -> S, fl: (S) -> Sn): View<Sn, E>
Commutative Monoid
ViewCombineExt(view).combine(other): View<(S, S2), ESuper>
A monoid is a type together with a binary operation (
combine) over that type, satisfying associativity and having an identity or empty element. Associativity facilitates parallelization by giving us the freedom to break problems into chunks that can be computed in parallel.
We can now construct a materialized view by using this View.
Materialized View #
MaterializedView uses or delegates to a View to handle events of type E
and maintain denormalized projection state. It represents the query side of
CQRS and belongs to the Application layer.
To handle an event, the materialized view fetches current state via
ViewStateRepository.fetchState, delegates the event to the view, and stores
the new state via ViewStateRepository.save.
MaterializedView extends ViewStateComputation and ViewStateRepository,
clearly communicating that it is composed out of these two behaviors.
final materializedView = createMaterializedView<
RestaurantOrderViewState?,
RestaurantOrderEvent,
NoMetadata
>(
view: restaurantOrderView(),
viewStateRepository: restaurantOrderViewRepository,
);
Example
typedef RestaurantOrderMaterializedView = MaterializedView<
RestaurantOrderViewState?,
RestaurantOrderEvent,
NoMetadata
>;
RestaurantOrderMaterializedView restaurantOrderMaterializedView({
required View<RestaurantOrderViewState?, RestaurantOrderEvent> view,
required ViewStateRepository<
RestaurantOrderEvent,
RestaurantOrderViewState?,
NoMetadata
> viewStateRepository,
}) {
return createMaterializedView(
view: view,
viewStateRepository: viewStateRepository,
);
}
The logic is orchestrated on the application layer. The components and functions are composed in different ways to support a variety of requirements.

Ephemeral View #
EphemeralView is a non-persistent query-side application component. It uses a
repository to fetch events for a query and a View to fold those events into a
view state.
Use it when the projection does not need to be stored, or when rebuilding the view state on demand is acceptable.
final ephemeralView = createEphemeralView(
view: restaurantOrderView(),
ephemeralViewRepository: restaurantOrderEphemeralViewRepository,
);
final state = await ephemeralView.handle(query);
Saga #
Saga is a datatype that represents the central point of control, deciding
what to execute next (A). It is responsible for mapping action results AR,
usually events, into actions A, usually commands.
Saga is stateless. It does not maintain state.
It has two generic parameters, AR and A, representing the types of values
that Saga may contain or use.
Saga is a pure domain component.
AR- Action ResultA- Action
final class Saga<AR, A> implements SagaContract<AR, A> {
const Saga({required this.react});
@override
final Stream<A> Function(AR actionResult) react;
}
Saga implements SagaContract to communicate the contract.
Example
Saga<RestaurantOrderEvent, RestaurantOrderCommand> restaurantOrderSaga() {
return Saga(
react: (event) async* {
switch (event) {
case RestaurantOrderCreatedEvent():
// Emit follow-up commands when the workflow requires it.
break;
case RestaurantOrderPreparedEvent():
break;
case RestaurantOrderRejectedEvent():
break;
}
},
);
}

Saga extensions and functions #
Contravariant
saga.mapLeftOnActionResult<ARn>((ARn actionResult) -> AR): Saga<ARn, A>
Covariant
saga.mapOnAction<An>((A action) -> An): Saga<AR, An>
Monoid
saga.combine(other): Saga<ARSuper, ASuper>
A monoid is a type together with a binary operation (
combine) over that type, satisfying associativity and having an identity or empty element. Associativity facilitates parallelization by giving us the freedom to break problems into chunks that can be computed in parallel.
We can now construct a SagaManager by using this Saga.
Saga Manager #
SagaManager is a stateless process orchestrator. It reacts on action results
of type AR and produces new actions A based on them.
Saga manager uses or delegates to a Saga to react on action results and
produce new actions, which are published via ActionPublisher.publish.
It belongs to the Application layer.
SagaManager extends SagaContract and ActionPublisher, clearly
communicating that it is composed out of these two behaviors.
final actionPublisher = createActionPublisher<
RestaurantOrderCommand,
NoMetadata,
NoMetadata
>(
publish: (actions) => actions,
publishWithMetadata: (actions) =>
actions.map((entry) => (entry.$1, noMetadata)),
);
final manager = createSagaManager<
RestaurantOrderEvent,
RestaurantOrderCommand,
NoMetadata,
NoMetadata
>(
saga: restaurantOrderSaga(),
actionPublisher: actionPublisher,
);
Example
typedef OrderRestaurantSagaManager = SagaManager<
RestaurantOrderEvent,
RestaurantOrderCommand,
NoMetadata,
NoMetadata
>;
OrderRestaurantSagaManager orderRestaurantSagaManager({
required SagaContract<RestaurantOrderEvent, RestaurantOrderCommand> saga,
required ActionPublisher<
RestaurantOrderCommand,
NoMetadata,
NoMetadata
> actionPublisher,
}) {
return createSagaManager(
saga: saga,
actionPublisher: actionPublisher,
);
}
Metadata, locking, and deduplication #
The application layer models metadata with generic metadata types instead of
fixing the public API to Map<String, Object>.
- Use
NoMetadataandnoMetadatawhen a flow does not use metadata. - Use
MapMetadatawhen map-based integration metadata is sufficient. - Prefer small typed metadata classes when metadata is part of your domain or infrastructure contract.
final class CommandMetadata {
const CommandMetadata(this.correlationId);
final String correlationId;
}
Locking variants make optimistic concurrency explicit:
EventSourcingLockingAggregateStateStoredLockingAggregateMaterializedLockingView
Deduplication is available for locking materialized views when event versions must be tracked:
MaterializedLockingDeduplicationView
The application API is intentionally still marked experimental until the remaining release-readiness work is complete.
Migration notes #
Kotlin Flow to Dart Stream #
The Kotlin reference uses Flow<T> for asynchronous streams of commands,
events, and actions. Dart uses Stream<T> for the same role.
- Kotlin
flowOf(event)maps toStream.value(event)or anasync*function withyield event. - Kotlin
emptyFlow()maps toStream.empty()or anasync*function that does not yield. - Kotlin
flatMapConcatmaps toasyncExpandwhen each source value should be expanded sequentially into another stream. - Kotlin
Flow<T>.fold(initial, operation)maps toawait stream.fold(initial, operation).
TS metadata intersections to Dart typing #
The TypeScript implementation can express metadata by intersecting structural types. Dart does not have TypeScript-style structural intersections, so this port models metadata explicitly with generic metadata parameters.
- Use
NoMetadatafor no metadata. - Use
MapMetadatafor dynamic integration metadata. - Use domain-specific metadata classes when the metadata shape should be stable and checked by the compiler.
Public API map #
package:fmodel/fmodel.dart exports the stable domain library and the
experimental application library.
Stable domain exports:
- Function types:
Decide,Evolve, andReact. Decider,DeciderContract, and decider composition helpers.View,ViewContract, and view composition helpers.Saga,SagaContract,saga, and saga composition helpers.
Experimental application exports:
- Aggregates:
EventSourcingAggregate,EventSourcingOrchestratingAggregate,EventSourcingLockingAggregate,EventSourcingLockingOrchestratingAggregate,StateStoredAggregate,StateStoredOrchestratingAggregate,StateStoredLockingAggregate, andStateStoredLockingOrchestratingAggregate. - Views:
MaterializedView,MaterializedLockingView,MaterializedLockingDeduplicationView, andEphemeralView. - Saga orchestration:
ActionPublisherandSagaManager. - Repositories:
EventRepository,EventLockingRepository,StateRepository,StateLockingRepository,ViewStateRepository,ViewStateLockingRepository,ViewStateLockingDeduplicationRepository, andEphemeralViewRepository. - Computation/default helpers:
EventComputation,EventOrchestratingComputation,StateComputation,StateOrchestratingComputation,ViewStateComputation, and repository default mixins. - Metadata helpers:
NoMetadata,noMetadata, andMapMetadata.
Internal implementation classes remain private and are not exported. Files under
lib/src should not be imported directly by package consumers; use
package:fmodel/fmodel.dart, package:fmodel/domain.dart, or
package:fmodel/application.dart.
Scope notes #
This Dart port intentionally does not include JVM-specific actor helpers from the Kotlin implementation. Dart concurrency is based on event loops, streams, futures, and isolates, so Kotlin coroutine actor helpers are not pending Dart application work.
This package also does not provide concrete database, broker, or web framework adapters. Those belong in application code or integration packages built on top of the repository and publisher interfaces.
Release status #
The current package version is 0.1.0-dev.1.
Release boundary:
0.1.0-dev.1is an experimental development release.- Domain APIs are treated as the stable core surface for this release line.
- Application APIs are public but experimental until the application repository boundary and release notes are finalized.
- The package name remains
fmodel, withfmodel.dart,domain.dart, andapplication.dartas the public library entrypoints. pubspec.yamlis configured for publishing through pub.dev.
CI enforces:
dart format --output=none --set-exit-if-changed .dart analyzedart test
Start using the library #
Add the package to a Dart project. While this port is unpublished, use a path or Git dependency.
dependencies:
fmodel:
path: ../fmodel-dart
Import the whole package:
import 'package:fmodel/fmodel.dart';
Or import a narrower library:
import 'package:fmodel/domain.dart';
import 'package:fmodel/application.dart';
Examples #


- Browse the Dart tests in
test/domainandtest/application. - Read the cross-language parity matrix in
doc/cross-language-parity-matrix.md. - Read the repository shape notes in
doc/application-repository-shape.md.
FModel in other languages #
References and further reading #
- Reference Guide
- Kotlin Conf 2024 Talk
- https://www.youtube.com/watch?v=kgYGMVDHQHs
- https://www.manning.com/books/functional-and-reactive-domain-modeling
- https://www.manning.com/books/functional-programming-in-kotlin
- https://www.47deg.com/blog/functional-domain-modeling/
- https://www.47deg.com/blog/functional-domain-modeling-part-2/
- https://www.youtube.com/watch?v=I8LbkfSSR58&list=PLbgaMIhjbmEnaH_LTkxLI7FMa2HsnawM_
Credits #
Special credits to Jérémie Chassaing for sharing his
research and Adam Dymitruk
for hosting the meetup.
Original FModel authorship by Ivan Dugalic (gh: @Idugalic) and Fraktalio.
Ephemeral View contribution by Domenic (gh: @DomenicDev).
Dart port by Dastan Abdrakhmanov (gh: @dclimber).
Porting assistance used OpenAI Codex 5.3, 5.4, and 5.5 models.