DEV Community

Cover image for Stateful Serverless - Cloud Functions without Amnesia
Horda
Horda

Posted on

Stateful Serverless - Cloud Functions without Amnesia

In our previous post, we explored how Horda entities work like Flutter's stateful widgets, but on the backend. We also mentioned that Horda is a stateful serverless platform. But what does that mean exactly?

If you are a Flutter developer, whenever someone mentions the word "serverless", there's a high chance that Firebase Cloud Functions would come to mind. Except unlike Horda, they are stateless.

So in this post let's explore what's the difference between stateful and stateless serverless, and how stateful serverless replaces traditional databases with a much faster, simpler, and more cost-efficient approach.

The Problem with Stateless Functions

When we say Firebase Cloud Functions are stateless, we mean they have complete amnesia. Every time a function is invoked, it starts from scratch with no memory of previous calls. It's like having a conversation where the other person forgets everything the moment you stop talking.

Let's look at a practical example to see how this plays out. Imagine you need to implement a bank account withdrawal: check if there's sufficient balance and update it atomically to prevent race conditions.

Here's how you'd implement this with Firebase Cloud Functions:

exports.withdrawFunc = onCall(async (request) => {
  const { accountId, amount } = request.data;
  const db = admin.firestore()

  // Validate request data
  if (!accountId || typeof accountId !== 'string') {
    throw new HttpsError('invalid-argument', 'Invalid accountId');
  }
  if (typeof amount !== 'number' || amount <= 0) {
    throw new HttpsError('invalid-argument', 'Invalid amount');
  }

  const accountRef = db.collection('accounts').doc(accountId);

  // Use transaction to handle race conditions
  return await admin.firestore().runTransaction(async (transaction) => {
    // Read account document from Firestore
    const accountDoc = await transaction.get(accountRef);

    // Check if account exists
    if (!accountDoc.exists) {
      throw new HttpsError('ACCOUNT_NOT_FOUND');
    }

    // Check if balance is sufficient
    const currentBalance = accountDoc.data().balance;
    if (currentBalance < amount) {
      throw new HttpsError('INSUFFICIENT_FUNDS');
    }

    // Calculate new balance
    const newBalance = currentBalance - amount;

    // Write back to Firestore
    transaction.set(accountRef, {
      balance: newBalance,
    });

    return { newBalance: newBalance };
  });
});
Enter fullscreen mode Exit fullscreen mode

Now, here's the same operation with Horda:

class BankAccount extends Entity<BankAccountState> {
  Future<RemoteEvent> withdraw(
    WithdrawAccount command,
    BankAccountState state,
    EntityContext ctx,
  ) async {
    if (state.balance < command.amount) {
      throw AccountException('INSUFFICIENT_FUNDS');
    }

    return AccountBalanceUpdated(
      newBalance: state.balance - command.amount,
    );
  }
}

@JsonSerializable()
class BankAccountState extends EntityState {
  double balance = 0;

  void balanceUpdated(AccountBalanceUpdated event) {
    balance = event.newBalance;
  }
}
Enter fullscreen mode Exit fullscreen mode

Look at the difference! The Firebase version is 40+ lines, while Horda is just 20. But it's not just about line count. Let's see what's really happening here.

Breaking Down the Differences

Database Roundtrips vs State Ready to Use

Firebase Cloud Functions:

// Read account document from Firestore
const accountDoc = await transaction.get(accountRef);

// Check if account exists
if (!accountDoc.exists) {
  throw new HttpsError('ACCOUNT_NOT_FOUND');
}

// Check if balance is sufficient
const currentBalance = accountDoc.data().balance;
Enter fullscreen mode Exit fullscreen mode

Every single time your function runs, it has to fetch the account data from Firestore. The function remembers nothing. Even if the same account was accessed milliseconds ago, it starts from scratch with a database roundtrip. And each of these roundtrips costs you money, so you have to remember that Firestore charges per document read, write, and delete.

Horda:

Future<RemoteEvent> withdraw(
  WithdrawAccount command,
  BankAccountState state,  // Current state is right here!
  EntityContext ctx,
) async {
  // You can easily access state to check the balance
  if (state.balance < command.amount) {
    throw AccountException('INSUFFICIENT_FUNDS');
  }

  return AccountBalanceUpdated(
    // And calculate the new balance
    newBalance: state.balance - command.amount,
  );
}
Enter fullscreen mode Exit fullscreen mode

The entity provides its state in the handler, so you can read state.balance immediately. No database calls. No roundtrips. No per-read charges. This is what we mean by stateful serverless: your code has access to state without manually having to fetch it from a database every time.

Database Orchestration Overhead vs Pure Business Logic

Firebase Cloud Functions:

const { accountId, amount } = request.data;
const db = admin.firestore()

// Validate request data
if (!accountId || typeof accountId !== 'string') {
  throw new HttpsError('invalid-argument', 'Invalid accountId');
}
if (typeof amount !== 'number' || amount <= 0) {
  throw new HttpsError('invalid-argument', 'Invalid amount');
}

const accountRef = db.collection('accounts').doc(accountId);

return await admin.firestore().runTransaction(async (transaction) => {
  const accountDoc = await transaction.get(accountRef);

  // Check if account exists
  if (!accountDoc.exists) { /* ... */ }

  // Get balance from document
  const currentBalance = accountDoc.data().balance;

  // ...

  transaction.set(accountRef, {
    balance: newBalance,
  });

  return { newBalance: newBalance };
});
Enter fullscreen mode Exit fullscreen mode

Look at how much code is dedicated to database operations: initializing Firestore, validating inputs, creating references, wrapping in transactions, reading, writing. Your actual business logic (checking balance and calculating the new amount) is buried somewhere in the middle. This is the 80/20 problem: 80% database orchestration, 20% actual logic.

Horda:

  if (state.balance < command.amount) {
    throw AccountException('INSUFFICIENT_FUNDS');
  }

  return AccountBalanceUpdated(
    newBalance: state.balance - command.amount,
  );
Enter fullscreen mode Exit fullscreen mode

This is pure business logic. Check the balance. Calculate the new amount. Return what happened. No database code at all. Horda handles persistence behind the scenes: when your command handler returns an event, state is automatically updated and persisted to a state-of-the-art storage layer purpose-built for stateful serverless workloads. Horda also handles scaling and replication so your entities remain fast and available under any load.

Transaction Complexity vs Automatic Serialization

Firebase Cloud Functions:

// Use transaction to handle race conditions
return await admin.firestore().runTransaction(async (transaction) => {
  // Read account document from Firestore
  const accountDoc = await transaction.get(accountRef);

  // ... validation and logic

  // Write back to Firestore
  transaction.set(accountRef, {
    balance: newBalance,
  });

  return { newBalance: newBalance };
});
Enter fullscreen mode Exit fullscreen mode

Without transactions, you'd have race conditions. Two simultaneous withdrawals could read the same balance, both think there's enough money, and both succeed, even if the account doesn't have enough for both. So you wrap everything in a Firestore transaction, which adds complexity and another layer of nesting.

Horda:

  // Entity receives commands
  WithdrawAccount command,

  // And produces events
  return AccountBalanceUpdated(
    newBalance: state.balance - command.amount,
  );
Enter fullscreen mode Exit fullscreen mode

No transaction code needed. Horda entities work by executing business logic upon receiving commands and producing events which update the state. When multiple commands come in for the same entity, Horda automatically queues them and processes them one at a time, in order, with the correct state for each subsequent command. Your code runs with guaranteed consistency. Race conditions simply can't happen.

You also benefit cost-wise. A simple withdrawal operation requires at least one read (inside the transaction) and one write. If there's contention and the transaction retries, those costs multiply. High-traffic operations can quickly rack up significant database bills.

Learning Curve: JavaScript vs Dart

Firebase Cloud Functions:

exports.withdrawFunc = onCall(async (request) => {
  const { accountId, amount } = request.data;

  // Validate request data manually
  if (!accountId || typeof accountId !== 'string') {
    throw new HttpsError('invalid-argument', 'Invalid accountId');
  }
  if (typeof amount !== 'number' || amount <= 0) {
    throw new HttpsError('invalid-argument', 'Invalid amount');
  }

  // Other JavaScript code, Firestore API, HTTP errors...
});
Enter fullscreen mode Exit fullscreen mode

As a Flutter developer, you're switching to JavaScript (or TypeScript), learning Firebase's API, understanding request/response patterns, and dealing with a completely different programming model. You also have to manually validate all incoming data because JavaScript doesn't have strong typing by default.

Horda:

// Command handler - just a Dart function, with well defined signature
Future<RemoteEvent> withdraw(
  WithdrawAccount command,
  BankAccountState state,
  EntityContext ctx,
) async { /* ... */ }

// Your command, event, and state - strongly typed Dart classes with serialization
@JsonSerializable()
class WithdrawAccount extends RemoteCommand { /* ... */ }

@JsonSerializable()
class AccountBalanceUpdated extends RemoteEvent { /* ... */ }

@JsonSerializable()
class BankAccountState extends EntityState { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

You're writing Dart, the same language you use for Flutter. Your command handler is just a regular Dart function. Your commands, events, and state are strongly typed classes with @JsonSerializable annotations, which means:

  • No manual validation needed - Dart's type system ensures amount is a double, not a string or null
  • Automatic serialization - The @JsonSerializable annotation generates all the serialization code for you
  • Familiar patterns - As described in the previous post, everything feels natural because it's the same language and patterns you already know

Try It Out!

Ready to experience stateful serverless for yourself? The Horda SDK includes a local development server, so you can start building on your own machine before deploying to production.

Check out these resources to get started:

We'd love to hear your thoughts and feedback as you explore!

Top comments (0)