fmodel 0.1.0-dev.1 copy "fmodel: ^0.1.0-dev.1" to clipboard
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 domain library 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 application library orchestrates the execution of the logic by loading state, executing domain components, 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.

event-modeling

Table of Contents #

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 uses Flow<T>.
  • Dart records, for example (State, Version), where Kotlin uses Pair.
  • Constructor-like top-level functions such as createEventSourcingAggregate where 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/S on the input,
  • when Command/C is handled on the input,
  • expect a Stream of new Events/E to 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/S on the input,
  • when Event/E is handled on the input,
  • expect new State/S to 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

event sourced vs state stored

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 decide function, (C, S) -> Stream<E>, over S to (C, Stream<E>) -> Stream<E>. This is an event-sourced system.
  • map our decide function, (C, S) -> Stream<E>, over E to (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:

onion architecture image

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 - Command
  • S - State
  • E - 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 image

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.

combine is 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.

event sourced aggregate

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.

state stored aggregate

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.

aggregates-application-layer

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 - State
  • E - 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 image

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.

materialized-views-application-layer

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 Result
  • A - 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 image

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 NoMetadata and noMetadata when a flow does not use metadata.
  • Use MapMetadata when 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:

  • EventSourcingLockingAggregate
  • StateStoredLockingAggregate
  • MaterializedLockingView

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 to Stream.value(event) or an async* function with yield event.
  • Kotlin emptyFlow() maps to Stream.empty() or an async* function that does not yield.
  • Kotlin flatMapConcat maps to asyncExpand when each source value should be expanded sequentially into another stream.
  • Kotlin Flow<T>.fold(initial, operation) maps to await 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 NoMetadata for no metadata.
  • Use MapMetadata for 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, and React.
  • 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, and StateStoredLockingOrchestratingAggregate.
  • Views: MaterializedView, MaterializedLockingView, MaterializedLockingDeduplicationView, and EphemeralView.
  • Saga orchestration: ActionPublisher and SagaManager.
  • Repositories: EventRepository, EventLockingRepository, StateRepository, StateLockingRepository, ViewStateRepository, ViewStateLockingRepository, ViewStateLockingDeduplicationRepository, and EphemeralViewRepository.
  • Computation/default helpers: EventComputation, EventOrchestratingComputation, StateComputation, StateOrchestratingComputation, ViewStateComputation, and repository default mixins.
  • Metadata helpers: NoMetadata, noMetadata, and MapMetadata.

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.1 is 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, with fmodel.dart, domain.dart, and application.dart as the public library entrypoints.
  • pubspec.yaml is configured for publishing through pub.dev.

CI enforces:

  • dart format --output=none --set-exit-if-changed .
  • dart analyze
  • dart 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 #

decider demo implementation

decider demo test

FModel in other languages #

References and further reading #

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.