Refactoring Legacy: Part 2 - Tell, Don't Ask.

Refactoring Legacy: Part 2 - Tell, Don't Ask.

It will always break.

You could write flawless code. You could have 100% test coverage, multi-region failover, circuit breakers, retries, the works.

Something will still break.

Anyone who doubts this is making a dangerous assumption. Your system runs on top of other systems that you have no control over. DNS, cloud providers, third-party APIs, payment gateways. They all have their own failure modes or bad days.

The question isn't if something breaks. It's what happens when it does.

I've only recently discovered Temporal and I genuinely wish I’d found it sooner. Let me show you why.

Durable Execution Solutions
Build invincible apps with Temporal’s open source durable execution platform. Eliminate complexity and ship features faster. Talk to an expert today!

When I first landed on the Temporal homepage, I didn't get it. Lots of "buzz word bingo": durable execution platform, invincible apps, task queues, workflows, activities. They’re technically accurate but they don’t answer the only question that actually matters

What can you do with it?

Here's a real world use case, using the same case study from Part 1:

A customer purchases a vehicle insurance policy on an external website. A JSON payload is then POST'ed to an existing legacy endpoint, which:

  • creates or updates a Customer record
  • creates or updates a Vehicle record
  • creates a Policy record
  • creates financial records
  • generates documentation
  • sends the documentation via email
  • creates an invoice in external financial software

With the recent AWS DynamoDB outage - this caused a bit of a headache. The legacy codebase tries to do all of the above at once, there's no idempotency, no retries. The AWS outage took out the Xero API for a few hours - thankfully the API call to Xero is last on the list, everything worked successfully. If it had been earlier on in the chain the required manual fix would have taken a lot longer.

In the past I might have tackled the above by using queues and sequential messages.

job → queue → job → queue → job → …

One task finishes, it dispatches the next. It works… until you need to answer questions like:

  • Where did it fail?
  • What already succeeded?
  • Can I retry from step 3 without redoing step 1?
  • What state was the system in at the moment of failure?

Queues don’t answer those questions.

This is where Temporal comes in.

Temporal

At its core, Temporal splits your system into two distinct concepts. Workflows and Activities.

Workflows

Workflows are the business process. They define what should happen and in what order. This code must be deterministic - if you ran it 100 times, it would produce the exact same path 100 times.

  • No randomness: You cannot use rand() or uniqid().
  • No side effects: You cannot touch the database or filesystem.
  • No external state: You cannot inject a database connection or a HTTP client.

Temporal doesn't store the state of your variables in a database. It stores the history of events. To restore your state after a crash or a long sleep, it replays your workflow. If the code does something different the second time around (like generating a different random ID) the replay breaks.

Activities

Activities are the side effects. This is where you interact with a database, make API calls, upload files. These are the steps that could fail or return different results each time they run.

By separating the State (Workflow) from the Side Effects (Activities), Temporal guarantees that a network failure in an API call doesn't crash the entire business process. The Workflow calls an Activity, the Activity does the work and reports back.

Below is a simplified version of the actual code. I’ve stripped away a lot of it - to focus purely on the relationship between the Workflow and the Activities.

The Workflow: Orchestrates the process. It captures the return values (like $policy and $documentId) and passes them into the next step.


declare(strict_types=1);

namespace App\Modules\Policy\Temporal\PolicyPurchase\Workflow;

// ... imports simplified for readability

#[WorkflowInterface]
#[AssignWorker(name: 'default')]
class PolicyPurchaseWorkflow implements PolicyPurchaseWorkflowInterface
{
    private PolicyPurchaseActivitiesInterface $activities;

    public function __construct()
    {
        // Define how we handle failures (e.g., Xero is down)
        $retry = new RetryOptions()
            ->withInitialInterval(DateInterval::createFromDateString('2 seconds'))
            ->withBackoffCoefficient(2.0)
            ->withMaximumAttempts(4);

        // Create the activity stub
        $this->activities = Workflow::newActivityStub(
            PolicyPurchaseActivitiesInterface::class,
            ActivityOptions::new()->withRetryOptions($retry)
        );
    }

    public function run(PolicyPurchaseCommand $policyPurchaseCommand): void
    {
        // Step 1: Issue the policy (Returns the Policy object)
        $policy = yield $this->activities->issuePolicyContract($policyPurchaseCommand);

        // Step 2: Generate Docs (Returns the Document ID)
        $documentId = yield $this->activities->generatePolicyDocuments($policy->id);
        
        // Step 3 & 4: Send communications (Using data from previous steps)
        yield $this->activities->emailCustomer($policy->id, $documentId);
        yield $this->activities->emailAccounts($policy->id, $documentId);

        // Step 5: Sync with Accounting
        yield $this->activities->updateXero($policy);
    }
}

The Activities: Handle the heavy lifting: talking to S3, generating PDFs, sending emails and communicating with Xero.

<?php

declare(strict_types=1);

namespace App\Modules\Policy\Temporal\PolicyPurchase\Activities;

// ... imports simplified for readability

#[ActivityInterface]
#[AssignWorker(name: 'default')]
final class PolicyPurchaseActivities implements PolicyPurchaseActivitiesInterface
{
    public function __construct(
        private EmailService $emailService,
        private IssuePolicyContract $issuePolicyContract,
        private PolicyService $policyService,
        private S3Service $s3Service
    ) {}

    public function issuePolicyContract(PolicyPurchaseCommand $cmd): Policy 
    {
        // Pure domain logic execution
        return $this->issuePolicyContract->execute($cmd);
    }

    public function generatePolicyDocuments(PolicyId $policyId): DocumentId
    {
        // Logic to generate PDF and upload to S3...
        return new DocumentId('...'); 
    }

    public function emailCustomer(PolicyId $policyId, DocumentId $documentId): void
    {
        // Re-hydrate the domain objects from the ID
        $policy = $this->policyService->findOrThrow($policyId);
        
        // Perform the side-effect (Download PDF)
        $pdfPath = $this->s3Service->downloadToTemp($documentId);

        $email = new CustomerPolicyDocumentationEmail(
            to: $policy->policyHolder()->email(),
            policy: $policy,
            attachments: [$pdfPath]
        );

        // Perform the side-effect (Send Email)
        $this->emailService->send($email);
    }

    // ... emailAccounts implementation ...

    public function updateXero(PolicyId $policyId): void
    {
        // API call to Xero...
    } 
}

You'll notice the yield keyword. In standard PHP, this is for Generators. In Temporal, this is a Checkpoint.

When the code hits yield, it saves the entire state of the workflow to the Temporal server. It then effectively "pauses" the code.

Unlike a typical queue worker, it doesn't consume CPU or memory while it waits. It could wait 100 milliseconds or 100 years. It doesn't care and neither should you.

This completely changes how we scale. You no longer scale based on the number of concurrent requests - you scale based on concurrent active work.

Uber uses this pattern to orchestrate over 12 billion workflow executions every month.

They run workflows for everything from driver onboarding to food delivery. A single food delivery workflow might stay 'open' for 45 minutes.

Prepare → Pickup → Deliver

The worker is only active for milliseconds at a time, only when the state changes.

If they tried to keep a standard queue worker open for the duration of every pizza delivery they’d bankrupt the company on cloud hosting bills.

Retries

Every legacy codebase has that one bit of functionality - it does too many things, it's inefficient, it's a blocker to upgrading other parts of the codebase. But no-one touches it because it's critical to the business and it mostly just works.

Temporal gives me the confidence to finally touch that code. Something will inevitably go wrong, but I can see exactly where and why it failed. The original input is still there. I can fix the bug and replay from the point of failure - with the same data, in the same context.

This works because Temporal saves the Event History: a log of everything that happened.

When a workflow needs to recover, it replays your code from the beginning. But it doesn't redo the work. It checks the history at each step. Already sent that email? Skip it, inject the recorded result, move on. Haven't called Xero yet? That's where we pick up.

Idempotency

Idempotency is a simple idea: running an operation twice (or a hundred times) should have the same effect as running it once.

Flipping a light switch "on" is idempotent, flip it ten times. The light's still on.

Temporal guarantees your activities will run at least once. Not exactly once. At least once.

That distinction matters. A network blip, a worker crash, any number of things you have no control over could cause Temporal to re-try an activity that actually completed.

This means your activities need to handle duplicates gracefully. If you're charging a credit card, check whether the payment already exists. If you're sending an email, decide whether a duplicate matters.

Idempotency keys make this easier. Most payment APIs: Stripe, Xero, PayPal - already support them. Pass a unique key with each request (your workflow ID for example), and the API won't process it twice. The retry becomes a no-op.

For your own code, the pattern is similar: derive a stable ID from the input and check it before doing any work. Already created this policy? Return the existing one. Already sent this notification? Skip it.

That covers reliability. But Temporal changed how I think about something else entirely: where state lives.

The Price of Amnesia

HTTP is stateless. REST APIs are stateless. In a "Shared-Nothing" architecture (like PHP, Serverless functions or containerized services) the process handles a single request and then terminates.

In many cases, this is exactly what we want. It makes scaling easy. Just add more processes.

As soon as a workflow spans more than one request, “state” becomes something you must reconstruct over and over. You end up with several competing realities:

  • What the Database thinks happened.
  • What the API returned 500ms ago.
  • What the React frontend is currently showing the user.

Why does statelessness hurt here?

Because every request that hits your service is like waking your blackout drunk friend who fell asleep in the bath.

They open their eyes, confused, wonder why they chose half a pizza for a pillow and why they're holding a spatula. You have to explain to them what happened last night, how they ended up in the bathtub and how they're supposed to be taking their partner out for a meal in a few hours.

They curse under their breath, sit up, put on a single sock and then pass out again.

Shortly afterwards, you wake them back up and explain everything again. Just for them to put on the other sock and then pass out again.

Your frontend code often wakes up with this same amnesia. It loads, fetches a user ID, and then has to ask: Where am I? Is this case valid? What screen should I show next? This forces your frontend to understand the entire business process.

This is the Ask model.

Tell, Don't Ask

The company I currently work for handle vehicle breakdown incidents. These are complex workflows that can take anywhere from hours to weeks to resolve. There are potentially many "real world" parties involved. Vehicle recovery operatives, mechanics, garages, long term car parks, hotels, replacement transport.

There are endless variances. Is the breakdown in the UK or Europe? What does the customer's policy cover? Is the vehicle a van or a motorcycle? Our current UI requires operators to memorize these variances to navigate the system. They need to know which page to visit, in what order, and which fields are mandatory for a specific policy type. It is a massive cognitive load.

A Proof of Concept

I started building a prototype where the backend dictates the interface. As it turns out, this pattern has a name: Server-Driven UI (SDUI).

The application exposes just three API endpoints:

  • Start an incident
  • Submit step data
  • Get incident state

The workflow manages four distinct steps (but it's trivial to add more):

  • Location lookup
  • Customer lookup
  • Vehicle lookup
  • Incident complete

The Temporal workflow is deceptively simple:

    /**
     * @return Generator<mixed, mixed, mixed, BreakdownIncident>
     */
    public function start(): Generator
    {
        $activity = Workflow::newActivityStub(
            BreakdownActivityInterface::class,
            ActivityOptions::new()
                ->withStartToCloseTimeout('10 seconds')
                ->withRetryOptions(RetryOptions::new()->withMaximumAttempts(10))
        );

        // --- STEP 1: Wait for Location Signal ---
        yield Workflow::await(fn(): bool => $this->locationInput instanceof LocationLookup);
        assert($this->locationInput instanceof LocationLookup);
        $this->locationDetails = yield $activity->lookupLocation($this->locationInput);

        // --- STEP 2: Wait for Customer Signal ---
        $this->currentStep = BreakdownWorkflowSteps::CUSTOMER_LOOKUP;
        yield Workflow::await(fn(): bool => $this->customerInput instanceof CustomerLookup);
        assert($this->customerInput instanceof CustomerLookup);
        $this->customerDetails = yield $activity->lookupCustomer($this->customerInput);

        // --- STEP 3: Wait for Vehicle Signal ---
        $this->currentStep = BreakdownWorkflowSteps::VEHICLE_LOOKUP;
        yield Workflow::await(fn(): bool => $this->vehicleInput instanceof VehicleLookup);
        assert($this->vehicleInput instanceof VehicleLookup);
        $this->vehicleDetails = yield $activity->lookupVehicle($this->vehicleInput);

        // --- COMPLETE ---
        $this->isComplete = true;
        $this->currentStep = BreakdownWorkflowSteps::COMPLETE;

        return new BreakdownIncident(
            caseId: Workflow::getInfo()->execution->getID(),
            status: BreakdownWorkflowSteps::COMPLETE->value,
            location: $this->locationDetails,
            customer: $this->customerDetails,
            vehicle: $this->vehicleDetails
        );
    }

The key magic here is Workflow::await. The workflow literally pauses execution at that line until the specific signal arrives.

Each step blocks until the expected input arrives. You can’t skip ahead. If you’re on step 2, step 1 has happened. Close the browser, restart the system, come back in ten years: the workflow is still waiting - in exactly the right place.

But a workflow that just waits isn't useful on its own. The UI needs to talk to it. We need two things: a way to pass data in (so the user can progress), and a way to query the current state (so the UI knows what to render).

Temporal makes both surprisingly straightforward.

Sending data (Signals)

A signal is effectively an inbox for the workflow. The UI sends structured data and the workflow picks up where it left off:

    #[SignalMethod]
    public function submitLocation(LocationLookup $data): void
    {
        $this->locationInput = $data;
    }

No polling, no session variables, no hidden state in the frontend.

You tell the workflow what happened - it moves itself forward.

Getting state (Queries)

Queries give you read-only access to the workflow’s live state.

This is how the UI knows which step to display.

    /**
     * @return array<string, mixed>
     */
    #[QueryMethod]
    public function getState(): array
    {
        return [
            'currentStep' => $this->currentStep->value,
            'isComplete' => $this->isComplete,
            'requiredSchema' => BreakdownStepMapper::map($this->currentStep),
        ];
    }

The API returns exactly what the user interface needs - no more, no less.

There’s no guesswork. The UI doesn’t decide what step it’s on.

The workflow tells it.

Generating the UI From the Backend

At this point the workflow knows what step the user is on. We still need to tell the UI what to render. My first attempt was straightforward: convert simple PHP Data Transfer Objects (DTOs) directly into JSON Schema for react-jsonschema-form.

This worked fine for basic text fields, but it fell apart as soon as I needed richer UI controls. A DTO can tell the frontend that a colour property exists. It can’t tell it that the field should be a dropdown with the options:

Blue
Red
Silver
White

A bit of searching led me to Liform. It’s not a mainstream library, so it probably needs thorough vetting before production use. But it does something clever:

Liform can turn any Symfony Form into a JSON Schema.

Which means:

  • The backend defines the form and its validation rules
  • The frontend renders whatever the backend tells it to
  • UI behaviour becomes declarative instead of hardcoded

Here’s an example. The output below is the JSON Schema for the Customer step, returned directly from my getState endpoint:

{
    "step": "customer_lookup",
    "schema": {
        "title": "customer_form",
        "type": "object",
        "properties": {
            "name": {
                "type": "string",
                "title": "name",
                "propertyOrder": 1
            },
            "dateOfBirth": {
                "type": "string",
                "format": "date",
                "title": "dateOfBirth",
                "propertyOrder": 2
            },
            "email": {
                "type": "string",
                "title": "email",
                "widget": "email",
                "propertyOrder": 3
            },
            "phoneNumber": {
                "type": "string",
                "title": "phoneNumber",
                "propertyOrder": 4
            }
        },
        "required": [
            "name",
            "dateOfBirth",
            "email",
            "phoneNumber"
        ]
    },
}
  • The backend defines the fields, types, widgets and constraints
  • The frontend no longer needs to know anything about the domain
  • The Temporal workflow step → maps to a Symfony Form → becomes a JSON Schema → drives the UI

Here's the mapping layer:

final readonly class BreakdownStepMapper
{
    /**
     * @return class-string<FormTypeInterface<mixed>>|null
     */
    public static function map(BreakdownWorkflowSteps $step): ?string
    {
        return match ($step) {
            BreakdownWorkflowSteps::LOCATION_LOOKUP => LocationForm::class,
            BreakdownWorkflowSteps::CUSTOMER_LOOKUP => CustomerForm::class,
            BreakdownWorkflowSteps::VEHICLE_LOOKUP  => VehicleForm::class,
            BreakdownWorkflowSteps::COMPLETE        => null,
        };
    }
}

And here’s where Liform does its magic:

final readonly class FormJsonSchemaGenerator
{
    public function __construct(private FormFactoryInterface $formFactory, private Liform $liform)
    {
    }

    /**
     * @param class-string<FormTypeInterface<mixed>> $class
     * @return array<string, mixed>
     */
    public function generateFor(string $class): array
    {
        $form = $this->formFactory->create($class);

        return $this->liform->transform($form);
    }
}

What used to be several seperate concerns:

  • Backend validation rules
  • Frontend form definitions
  • Business workflow order

All of this is now managed by a single source of truth.

/**
 * @extends AbstractType<CustomerForm>
 */
class CustomerForm extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class)
            ->add('dateOfBirth', DateType::class, [
                'widget' => 'single_text',
                'html5' => false,
            ])
            ->add('email', EmailType::class)
            ->add('phoneNumber', TelType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'csrf_protection' => false,
            'data_class' => CustomerLookup::class,
            'empty_data' => fn(FormInterface $form): CustomerLookup => new CustomerLookup(
                $form->get('name')->getData() ?? '',
                $form->get('dateOfBirth')->getData() ?? '',
                $form->get('email')->getData() ?? '',
                $form->get('phoneNumber')->getData() ?? '',
            ),
        ]);
    }
}

The Symfony FormType is no longer just an implementation detail. It's the definition of the user journey. The workflow controls when a step happens, the FormType controls what that step is.

As a result, the UI no longer needs to store state, track progress, duplicate business logic, or decide what screen comes next. It just renders whatever the backend tells it to render.

Once the behaviour and structure of the interface live on the server, replacing the UI stops being a rewrite and becomes a swap. React today, mobile app tomorrow, CLI next year.

The screenshot below is the completed breakdown incident I triggered during development. Notice what Temporal gives you out of the box:

  • the exact case ID
  • when the workflow started and ended
  • every state transition
  • every activity call
  • every external signal sent from the UI
  • the final structured result

No digging around in logs. No guessing. No piecing information together from half a dozen systems.

Every step of the journey is visible, reproducible, and inspectable. Geolocate the customer. Find their record. Find their vehicle. Each one logged, timestamped, replayable.

If a step fails, you don’t get a stack trace lost somewhere in the logs. You see exactly which step failed, why it failed and you can restart the workflow from that point. Not from the beginning. Not after manually reconstructing state. From the precise step that failed.

I’ve published the code for this as a proof of concept. This is not production-ready. The happy path works; error handling and edge cases are rough. It's to demonstrate the architecture, nothing more.

GitHub - clegginabox/temporal-breakdown-handling
Contribute to clegginabox/temporal-breakdown-handling development by creating an account on GitHub.

Two important caveats:

No Form Validation

Right now, the forms accept whatever you type. In a real system you’d apply validation rules on the backend (and optionally mirror them in the UI).

Chatty Frontend Polling

The frontend asks the backend for the workflow state every 2 seconds:

// frontend/src/App.jsx
pollIntervalRef.current = setInterval(pollState, 2000);

Fine for a proof-of-concept. But you might notice a brief window where nothing seems to happen:

Frontend: "State?"
Backend : "Location Step"
Frontend submits Location
Frontend: "State?"
Backend : "Location Step"  ← Temporal hasn't processed the signal yet
Frontend: "State?"
Backend : "Customer Step"

In production, you’d replace this with something push-based:

  • Long Polling
  • WebSockets
  • Mercure (ideal if you’re already using Symfony / API Platform)

If you want to try it yourself, the repo includes a full docker-compose setup. Just run:

$ docker compose up -d --build

This is one approach. I'm not claiming it's the best one.

If you've tackled long-running workflows differently, or have opinions on SDUI, I'd genuinely like to hear about it. What's worked? What's fallen apart in production? I'm still learning.

Until next time 👋