Account Signup With FlowChassis Leaves No Half-Made Accounts
Signing someone up sounds like one action. It’s usually four. You create the user record, give the account its storage, send the welcome email, and switch the account on. Each of those talks to a different system, and any of them can fail while the others have already happened.
The usual result is a half-made account. The user row exists but the bucket doesn’t. Or the bucket exists, the email bounced, and nobody knows whether to try again. Someone writes a cleanup script. Then the cleanup script needs its own cleanup.
This case study is a complete program in the repository. It runs three signups through one flow, with the outside services faked so it runs anywhere, and each one goes a different way.

The Flow
Each step is a name, a Do, and if it changes something that can be taken back, an Undo. Here are the first two:
{
Name: "create_user",
Do: func(c *flowchassis.Ctx) error {
id, err := s.Users.Create(c, "create user "+input(c).Email)
if err != nil {
return err
}
c.Set("user", []byte(id)) // saved with the checkpoint
return nil
},
Undo: func(c *flowchassis.Ctx) error {
id, _ := c.Get("user")
return s.Users.Do(c, "delete user "+string(id))
},
},
{
Name: "provision_storage",
Do: func(c *flowchassis.Ctx) error {
id, err := s.Storage.Create(c, "create bucket, plan "+input(c).Plan)
if errors.Is(err, errNoRegion) {
// Retrying won't help: compensate now.
return flowchassis.Permanent(err)
}
if err != nil {
return err // anything else: try again
}
c.Set("bucket", []byte(id))
return nil
},
Undo: func(c *flowchassis.Ctx) error {
id, _ := c.Get("bucket")
return s.Storage.Do(c, "delete bucket "+string(id))
},
},
The new user’s ID goes into the run’s state with c.Set. It’s saved in the journal together with the step’s checkpoint, so if the step fails the half-set value goes away with it. The undo reads it back with c.Get, and gets the value exactly as that step left it.
The welcome email has no undo, because an email can’t be unsent. It does have its own retry policy, since mail providers time out:
{
Name: "send_welcome",
Do: func(c *flowchassis.Ctx) error {
return s.Mail.Do(c, "send welcome email to "+input(c).Email)
},
Retry: flowchassis.RetryPolicy{MaxAttempts: 5, Backoff: 100 * time.Millisecond},
},
What Happens
This is the program’s output, with only the closing lines about the journal’s path left out:
signup-ana:
users create user ana@example.com -> users-1
storage create bucket, plan free -> storage-1
mail send welcome email to ana@example.com -> done
users activate user users-1 -> done
=> completed
signup-ben:
users create user ben@example.com -> users-2
storage create bucket, plan pro -> storage-2
mail send welcome email to ben@example.com -> error: mail provider timed out
mail send welcome email to ben@example.com -> error: mail provider timed out
mail send welcome email to ben@example.com -> done
users activate user users-2 -> done
=> completed
signup-cy:
users create user cy@example.com -> users-3
storage create bucket, plan enterprise-eu -> error: no storage region offers plan enterprise-eu
users delete user users-3 -> done
=> compensated (step provision_storage: no storage region offers plan enterprise-eu)
Ana’s signup is the boring one, which is the point.
Ben’s mail provider times out twice. Those two failures were ordinary errors, so the engine waited and tried again, and the third attempt went through. His account was activated after the email went out, not before, and the steps before the email didn’t run twice.
Cy picked a plan the storage provider can’t serve. The storage step recognises that answer and wraps it in flowchassis.Permanent, which tells the engine that retrying won’t help. Any other error from the provider, a timeout say, stays a plain error and gets retried. So the run compensates straight away. Every completed step with an undo is reversed, newest first. Here that’s only the user record, because the bucket was never made. The run ends compensated, and there’s no account left behind for anyone to find.
The Second Request
A web signup handler gets retried. A browser resubmits, a load balancer times out and the client tries again. The program starts each signup with a fixed run ID:
id := "signup-" + in.Email[:len(in.Email)-len("@example.com")]
// Starting the same ID again finds the existing run, so a retried
// HTTP request can't sign someone up twice.
if _, err := e.Start("signup", id, b); err != nil {
return nil, err
}
Starting the same ID with the same input finds the existing run and returns it. It doesn’t start a second one. Run the program twice over the same directory and the second run calls nothing at all. It finds all three signups finished in the journal and prints their results.
Each step also gets c.Key, an idempotency key that stays the same across attempts and restarts. The fake services in the example honour it the way real providers’ APIs do: a call with a key they’ve already seen returns the first result and does nothing again. That covers the one case the journal can’t. A process can die after a service did the work but before the step’s checkpoint was written. When the step runs again, the service sees the same key and doesn’t do the work twice.
What To Take From It
The flow says what the work is, in order, and how to take each piece back. Each step decides which of its errors are worth another try. The engine does the waiting, the undoing and the bookkeeping. Nothing in the handler checks “did step two happen?” The journal knows.
To see one run’s full history, with times, run the flowchassis command on the journal the example leaves:
go run ./examples/signup -dir /tmp/signup
flowchassis show /tmp/signup/signup.journal signup-cy
The same idea scales to deploys, in the cloud provisioning case study, where an undo itself fails and the run has to stop and wait for a person.