Handling errors

Database operations can fail for different reasons: the service may be temporarily unavailable, the current user may not have permission, or the application may have sent an invalid query. Handling these cases separately makes the behavior clearer and avoids retrying operations that cannot succeed without a code or data change.

First distinguish absence from failure

Not finding a record is normally a valid result, not an exception:

final User? user = await dorm.users.repository.peek(userId);
final List<User> users = await dorm.users.repository.peekAll();

peek returns null when the record does not exist. A collection read returns an empty list when no records match. Use an error handler for failures reported by the database or service, not for these normal results.

Catch portable database errors

Official dORM engines translate recognized provider failures into DormDatabaseException. It gives the application one API that works with HTTP, SQL, Firebase, Firestore, and MongoDB:

try {
  final User user = await dorm.users.repository.put(creation);

  showUser(user);
} on DormDatabaseException catch (error, stackTrace) {
  if (error.isAvailabilityFailure) {
    showTemporaryFailure();
  } else if (error.kind == DormErrorKind.authentication) {
    await refreshSession();
  } else if (error.kind == DormErrorKind.authorization) {
    showPermissionError();
  } else if (error.kind == DormErrorKind.conflict ||
      error.kind == DormErrorKind.constraint) {
    showConflictError();
  } else {
    reportDatabaseFailure(error, stackTrace);
  }
}

Use kind for decisions that should remain independent of the selected engine. The complete list of categories and their meanings is in Portable database errors.

Decide when to retry

unavailable and timeout often indicate a temporary problem, but they do not automatically make every write safe to repeat. A failed write may have reached the provider even when the client did not receive its response.

Check retryability before retrying automatically:

on DormDatabaseException catch (error, stackTrace) {
  if (error.isRetryable) {
    await retryWithBackoff();
  } else {
    reportDatabaseFailure(error, stackTrace);
  }
}

If retryability is unknown, let the application decide. For writes, prefer an idempotent operation or an application-level way to identify a repeated attempt before adding automatic retries.

Keep provider details when diagnosing a failure

The portable category is intended for normal application behavior. When a diagnostic or a provider-specific recovery needs more information, the exception still exposes:

  • engine: the dORM engine that reported the failure;
  • operation: the operation being attempted, when known;
  • providerCode: the original HTTP status, SQLSTATE, SQLite result code, or another provider code;
  • cause: the original provider exception;
  • stackTrace: the original stack trace.

For example:

on DormDatabaseException catch (error, stackTrace) {
  log(
    'Database failure in ${error.engine}: ${error.operation}',
    error: error.cause,
    stackTrace: error.stackTrace ?? stackTrace,
  );
}

Do not depend on cause or providerCode for behavior that must work with another engine. Use DormErrorKind and DormRetryability for that behavior.

Handle validation and custom-engine errors

Errors raised by dORM before it calls a provider remain ordinary Dart errors. For example, an invalid argument, an unsupported local operation, or malformed data can be reported as ArgumentError, UnsupportedError, StateError, or FormatException.

Engines that do not implement the optional ErrorAwareEngine capability also keep their native errors. If your application supports custom engines, keep a final handler for errors that are not DormDatabaseException:

try {
  await runOperation();
} on DormDatabaseException catch (error, stackTrace) {
  handlePortableFailure(error, stackTrace);
} catch (error, stackTrace) {
  reportUnexpectedFailure(error, stackTrace);
}

Handle errors from streams

try/catch around pull or pullAll only covers errors thrown while the stream is being created. Errors that happen later arrive through the stream's error channel:

final subscription = dorm.users.repository.pullAll().listen(
  renderUsers,
  onError: (Object error, StackTrace stackTrace) {
    if (error is DormDatabaseException) {
      handlePortableFailure(error, stackTrace);
    } else {
      reportUnexpectedFailure(error, stackTrace);
    }
  },
);

Do not silently replace a failed stream with another source unless the application explicitly accepts repeated or missing snapshots. dORM keeps streams attached to their selected engine so that an automatic switch cannot lose events or emit duplicated state.

Errors with synchronized engines

When using SynchronizedEngine, finite reads try the primary engine first. Its default fallback policy only treats unavailable and timeout as temporary availability failures. It does not use a fallback for an empty result, authorization failure, invalid query, conflict, or an unknown error.

Writes still go to the primary first. A replica failure is kept for retry by the synchronization outbox and is not reported as a failed primary write. Streams remain connected to the primary and do not switch automatically.

You can provide a custom fallback policy when your application has a more specific definition of temporary failure. Keep that policy narrow: falling back for validation or permission errors would hide the actual problem.

Quick decision guide

Situation Recommended response
peek returns null Treat the record as absent.
A read returns [] Treat the query as having no matches.
unavailable or timeout Show temporary-failure UI; retry only when safe.
authentication Refresh credentials or ask the user to sign in.
authorization Explain that the operation is not permitted.
conflict or constraint Resolve the conflicting state or input.
invalidQuery or invalidData Fix the query, model, or data before retrying.
unknown or a native error Preserve diagnostics and investigate the cause.