monart

pub.dev CI

Railway-Oriented Programming for Dart. Build service objects that compose cleanly and never throw — every operation returns a Result, either a Success or a Failure, that can be chained, filtered, and handled without try/catch.

Inspired by the Ruby gem f_service.

Documentation


Installation

Add monart to your pubspec.yaml:

dependencies:
  monart: ^0.1.0

For test helpers (haveSucceededWith, haveFailedWith, MockService):

dev_dependencies:
  monart: ^0.1.0  # already included above if in dependencies

Then import:

import 'package:monart/monart.dart';         // core library
import 'package:monart/monart_testing.dart'; // test utilities

Quick start

import 'package:monart/monart.dart';

class UserCreateService extends ServiceBase<User> {
  const UserCreateService({required this.name, required this.email});

  final String name;
  final String email;

  @override
  Result<User> run() =>
      _requireName()
          .andThen((_) => _requireEmail())
          .andThen((_) => _persistUser());

  Result<String> _requireName() =>
      check('nameRequired', name, () => name.isNotEmpty);

  Result<String> _requireEmail() =>
      check('emailInvalid', email, () => email.contains('@'));

  Result<User> _persistUser() {
    final newUser = User(name: name, email: email);
    return newUser.save()
        ? success('userCreated', newUser)
        : failure('saveFailed', newUser);
  }
}

UserCreateService(name: 'Alice', email: 'alice@example.com')
    .call()
    .onSuccessOf('userCreated', (user) => redirectToDashboard(user))
    .onFailureOf('nameRequired', (_) => nameField.showError('Name is required'))
    .onFailureOf('emailInvalid', (email) => emailField.showError('$email is invalid'))
    .onFailure((outcome, _) => logger.error('Unexpected: $outcome'));

Usage

Result

Every service operation returns a Result<Value> — either a Success carrying a typed value, or a Failure carrying optional context. Results are never thrown; they are returned and composed.

final registration = UserCreateService(name: 'Alice', email: 'alice@example.com').call();

registration.isSuccess; // true
registration.outcome;   // 'userCreated'
registration.value;     // User(name: 'Alice', ...)

Reacting to outcomes

onSuccess and onFailure are fire-and-forget side effects. They return this, so calls can be chained:

registration
    .onSuccess((user) => print('Welcome, ${user.name}!'))
    .onFailure((outcome, _) => logger.warn('Failed: $outcome'));

Use onSuccessOf and onFailureOf to react to specific outcomes. Both accept a single String or a List<String>:

UserCreateService(name: 'Alice', email: 'alice@example.com')
    .call()
    .onSuccessOf('userCreated', (user) => redirectToDashboard(user))
    .onFailureOf('nameRequired', (_) => print('Name must be provided'))
    .onFailureOf('emailInvalid', (email) => print('$email is not a valid email'))
    .onFailure((outcome, _) => logger.error('Unexpected outcome: $outcome'));

Grouping related outcomes in a single call:

FetchDataService(url: url)
    .call()
    .onSuccessOf(['ok', 'fromCache'], (response) => render(response.body))
    .onFailureOf(['badGateway', 'internalServerError'], (_) => showRetryBanner())
    .onFailureOf('unauthorized', (_) => redirectToLogin())
    .onFailure((outcome, _) => logger.error('Unhandled: $outcome'));

Exhaustive handling with when

When you need a value from both branches, when forces you to handle both:

final message = registration.when(
  success: (outcomes, user)    => 'Welcome, ${user.name}!',
  failure: (outcomes, context) => 'Registration failed: ${outcomes.first}',
);

Pattern matching

Result is a sealed class, so Dart's exhaustive pattern matching works too:

switch (registration) {
  case Success(:final outcomes, :final value):
    print('${outcomes.first}: ${value.name}');
  case Failure(:final outcomes, :final context):
    print('${outcomes.first}: $context');
}

Multiple outcomes

A result can carry more than one outcome tag — useful for HTTP-like layered semantics:

failure(['unprocessableContent', 'clientError'], response)

Both onFailureOf('unprocessableContent', ...) and onFailureOf('clientError', ...) will match.


ServiceBase

Subclass ServiceBase<Value> and implement run() as a pipeline of andThen steps. Use the built-in helpers — success, failure, check, tryRun — to produce results without constructing Success/Failure directly.

class UserCreateService extends ServiceBase<User> {
  const UserCreateService({required this.name, required this.email});

  final String name;
  final String email;

  @override
  Result<User> run() =>
      _requireName()
          .andThen((_) => _requireEmail())
          .andThen((_) => _persistUser());

  Result<String> _requireName() =>
      check('nameRequired', name, () => name.isNotEmpty);

  Result<String> _requireEmail() =>
      check('emailInvalid', email, () => email.contains('@'));

  Result<User> _persistUser() {
    final newUser = User(name: name, email: email);
    return newUser.save()
        ? success('userCreated', newUser)
        : failure('saveFailed', newUser);
  }
}

check — inline validation

check runs a predicate and carries the data on both paths, so the caller always has the object that was validated:

// On success → Success('emailInvalid', 'alice@example.com')
// On failure → Failure('emailInvalid', 'notanemail')
check('emailInvalid', email, () => email.contains('@'))

tryRun — wrapping code that may throw

Use tryRun to call external APIs or repositories without try/catch in your service:

Result<Order> _fetchOrder() =>
    tryRun(
      'orderFetched',
      () => orderRepository.findById(orderId),
      onException: (exception, _) => switch (exception) {
        NotFoundException() => 'notFound',
        TimeoutException()  => 'timeout',
        _                   => null, // re-uses the outcome tag as context
      },
    );

Chaining services — Railway-Oriented Programming

andThen passes the success value to the next step. The first failure short-circuits the entire chain:

UserCreateService(name: 'Alice', email: 'alice@example.com')
    .call()
    .andThen((user) => UserSendWelcomeEmailService(user: user).call())
    .andThen((user) => AnalyticsTrackSignupService(userId: user.id).call())
    .onSuccess((user) => print('Onboarding complete: ${user.name}'))
    .onFailure((outcome, _) => print('Onboarding failed at: $outcome'));
// If UserCreateService fails with 'nameRequired', the two subsequent
// services never run — the chain ends at the first failure.

Use orElse to recover from a failure and continue the chain:

OrderFetchFromApiService(id: id)
    .call()
    .orElse((outcomes, _) => OrderFetchFromCacheService(id: id).call())
    .onSuccess((order) => render(order));

Use map to project the success value without adding a pipeline step:

UserCreateService(name: 'Alice', email: 'alice@example.com')
    .call()
    .map((user) => UserViewModel.from(user))
    .onSuccess((viewModel) => renderProfile(viewModel));

Async — FutureResult<Value>

FutureResult<Value> is a typedef for Future<Result<Value>>. The FutureResultX extension mirrors the full synchronous API, so async pipelines read the same way:

class OrderFetchService extends ServiceBase<Order> {
  const OrderFetchService({required this.orderId});
  final String orderId;

  @override
  Result<Order> run() => throw UnimplementedError('Use runAsync');

  Future<Result<Order>> runAsync() async =>
      tryRun(
        'orderFetched',
        () async => Order.fromJson(await ordersApi.get(orderId)),
        onException: (exception, _) => switch (exception) {
          TimeoutException() => 'timeout',
          NotFoundException() => 'notFound',
          _                  => null,
        },
      );
}

await OrderFetchService(orderId: 'ord-123')
    .runAsync()
    .andThen((order) => OrderSyncStatusService(order: order).runAsync())
    .onSuccessOf('orderFetched', (order) => print('Synced: ${order.id}'))
    .onFailureOf('timeout', (_) => print('Request timed out'))
    .onFailureOf('notFound', (_) => print('Order not found'))
    .onFailure((outcome, _) => print('Unexpected: $outcome'));

Testing utilities

Import package:monart/monart_testing.dart in your test files to access the matchers and mocking helpers.

Result matchers

haveSucceededWith and haveFailedWith accept a single String or List<String>. Chain .andValue or .andContext to also assert the carried value or context.

Both accept a plain value (compared with equals) or any Matcher from package:matcher:

import 'package:monart/monart_testing.dart';

final login = UserLoginService(email: email, password: password).call();
final signup = UserSignupService(name: name, email: email, password: password).call();

// outcome only
expect(login, haveSucceededWith('ok'));
expect(signup, haveFailedWith('unauthorized'));
expect(login, haveSucceededWith(['ok', 'cached']));
expect(signup, haveFailedWith(['unprocessableContent', 'clientError']));

// plain value / context
expect(login, haveSucceededWith('ok').andValue(expectedSession));
expect(signup, haveFailedWith('validationFailed').andContext({'email': ["can't be blank"]}));

// type check
expect(login, haveSucceededWith('ok').andValue(isA<UserSessionModel>()));
expect(signup, haveFailedWith('validationFailed').andContext(isA<Map<String, dynamic>>()));

// type + attribute assertions
expect(
  login,
  haveSucceededWith('ok').andValue(
    isA<UserSessionModel>()
      .having((session) => session.user.name, 'name', 'Alice')
      .having((session) => session.token, 'token', isNotEmpty),
  ),
);

// structure assertion
expect(signup, haveFailedWith('validationFailed').andContext(containsPair('email', contains("can't be blank"))));

mockService — intercepting service calls

mockService<MockedService> intercepts all .call() invocations of a service type and returns a fixed Result without executing run(). No dependency injection required — the production code stays untouched.

Use it in setUp with addTearDown to ensure a clean state between tests:

setUp(() {
  mockService<UserCreateService>(Success('userCreated', alice));
  addTearDown(clearServiceMocks);
});

This lets you test a service that depends on others by controlling what those dependencies return, without re-testing their internal logic:

// OrderOrchestrator calls UserCreateService internally — no DI needed
class OrderOrchestrator extends ServiceBase<Order> {
  @override
  Result<Order> run() =>
      UserCreateService(name: name, email: email)
          .call()
          .andThen((user) => OrderCreateService(user: user).call());
}

// In tests — treat UserCreateService as a black box:
setUp(() {
  mockService<UserCreateService>(Success('userCreated', alice));
  addTearDown(clearServiceMocks);
});

it('creates the order when the user is valid', () {
  expect(OrderOrchestrator(name: 'Alice', email: 'alice@example.com').call(),
      haveSucceededWith('orderCreated'));
});

it('stops the pipeline when user creation fails', () {
  mockService<UserCreateService>(Failure('emailInvalid'));
  expect(OrderOrchestrator(name: 'Alice', email: 'bad').call(),
      haveFailedWith('emailInvalid'));
});

mockService accepts any Result — use Success and Failure directly:

mockService<UserCreateService>(Success('userCreated', alice));
mockService<UserCreateService>(Success(['ok', 'cached'], alice));
mockService<UserCreateService>(Failure('unauthorized'));
mockService<UserCreateService>(Failure('validationFailed', errors));

MockService — explicit injection

For the cases where a service genuinely accepts different implementations (a payment orchestrator that works with Pix or credit card, for example), MockService<Value> is a ServiceBase you can inject directly:

class PaymentOrchestrator extends ServiceBase<Receipt> {
  const PaymentOrchestrator({required this.paymentService});

  final ServiceBase<Receipt> paymentService; // Pix, CreditCard, or mock

  @override
  Result<Receipt> run() => paymentService.call();
}

// In tests:
final orchestrator = PaymentOrchestrator(
  paymentService: MockService.success('paid', receipt),
);

expect(orchestrator.run(), haveSucceededWith('paid'));

All three constructors are available:

MockService<Receipt>(Success('paid', receipt))
MockService<Receipt>.success('paid', receipt)
MockService<Receipt>.failure('declined')
MockService<Receipt>.failure('declined', errorDetails)

Contributing

Running tests and analysis locally

dart pub get
dart analyze --fatal-infos
dart test

Workflows

CI runs automatically on every push to master and on every pull request. Tests and static analysis are executed against three SDK versions — 3.0.0 (the declared minimum), stable, and beta. Only stable is required to pass; the other two run with continue-on-error so you get early visibility without blocking merges.

Release is triggered by pushing a version tag:

git tag v0.1.0
git push origin v0.1.0

The workflow runs the full test suite and then creates a GitHub Release with auto-generated release notes. No manual steps needed beyond the tag.

Docs are deployed to GitHub Pages automatically when the version: line in pubspec.yaml changes on master. To force a deploy without bumping the version, go to Actions → "Deploy Docs" → "Run workflow".

Publish is always manual. After the release tag exists, go to Actions → "Publish to pub.dev" → "Run workflow", enter the version (e.g. 0.1.0), and confirm. The workflow checks out that exact tag, re-runs tests and analysis, does a --dry-run, and only then publishes.

Publishing uses OIDC (Trusted Publishers) — no tokens stored as secrets. First-time setup: on pub.dev go to "My pub.dev" → "Trusted publishers" and add github.com/bvicenzo/monart.


License

MIT

Libraries

monart
monart — Railway-Oriented Programming for Dart.
monart_testing
Testing utilities for monart.