Durable Execution Modules: Composable Building Blocks for Complex Ruby Workflows
Durable execution platforms are built to handle long-running processes with strong guarantees around retries, state persistence, and fault tolerance. It shines when you need to orchestrate complex workflows that span minutes, hours, or even days, without losing state when a worker crashes or deploys. Leveraging tools like Temporal(opens in a new tab), we are able to build orchestration workflows alongside the rest of our application code.At its core, Temporal provides two primary primitives:
Workflows: Deterministic orchestration code that coordinates business logic.
Activities: Side-effectful operations like API calls, database writes, or external integrations.
This separation works extremely well, and it provides a strong separation of concerns. However, it does not allow for us to build reusable workflow components, and it forces us into the situation of re-inventing the wheel situation when building many similar, yet different, complex workflows.
A Mental Model: The Orchestration Abstraction Ladder
A useful way to think about Temporal primitives is as layers of abstraction:
Modules fill in the missing middle layer: too orchestration-heavy for Activities, too lightweight for another Workflow.
The Problem: Activities Aren’t Enough for Workflow Composition
Workflows are built as a culmination of Activities, and features to manage the timing of those Activities (timers, waits, signals, updates, queries, retries, etc.).
When it comes to modularizing your Workflows, what you want is something closer to a “mini-workflow”: a reusable chunk of orchestration logic that itself may call activities, wait on signals, or manage state. Activities alone do not provide the ability to create well-structured building blocks that can be reused across multiple workflows because they do not have access to those Workflow specific features to manage the holistic execution of the modularized chunk.
Temporal does have a concept of Child Workflows, which are Workflows executed inside another Workflow. However, these come with caveats:
Child workflows do not share state with the parent
Communication between parent and child is asynchronous and more complex than local calls
They introduce additional operational and cognitive overhead
Temporal’s guidance is:
When in doubt, use an Activity.
This is sound advice, but Activities don’t give us all the tools we need to avoid violating the DRY principle when orchestrating complex workflows.
Introducing Workflow Modules
In our Rails application, we wanted a way to define reusable orchestration steps that are blocks of workflow logic that:
Execute activities
Wait on state values, timers, or signals
Define signal and update handlers
Maintain workflow-local state
Are composable and extensible
We call these Workflow Modules.
The idea is simple: use Rails Concerns to package reusable workflow orchestration logic.
Why Concerns?
Concerns provide:
A clean mechanism for composition via include
Hooks like included do .. end
Shared methods, constants, and helpers
Minimal magic and no Temporal-specific coupling
This makes them ideal for packaging reusable workflow behavior.
A Real Example: Money Movement Building Blocks
In our international payments domain, we have multiple workflows that move money in different ways in order to serve the different use cases of international contractors and employer of record employees. Each workflow has variations, but many share the same orchestration steps:
ACH Collections from our customer’s US bank account
Wire funds to our local banking partner
Separate taxes and/or fees
Execute external transfer
Handle cancellations and refunds
Instead of copying orchestration logic into each workflow, we extracted workflow modules.
# app/durable_execution/workflow/module/collections.rb
module Workflow
module Module
module Collections
extend ActiveSupport::Concern
def module_collections_execute!
@state.transfer = Activity::UpdateTransfer.execute!(
@state.transfer.id,
Transfer::Status::COLLECTION_PENDING,
)
transaction = Activity::CreateTransaction.execute!(
@state.transfer.id,
Transaction::Type::ACH,
)
@state.transfer = Activity.UpdateTransfer.execute!(
@state.transfer.id,
Transfer::Status::COLLECTION_PROCESSING,
{ collections_transaction: transaction.id }
)
Temporalio::Workflow.wait_condition do
[
Transfers::Status::COLLECTION_SUCCEEDED,
Transfers::Status::COLLECTION_FAILED,
].include?(@state.transfer.status)
end
end
included do
## SIGNAL HANDLERS
workflow_signal(
name:"collection_updated",
description: "The ACH Collections Transaction has been updated",
)
def collection_updated(input)
input = input.deep_symbolize_keys
case input[:status]
when Transaction::Status::SUCCEEDED
@state.transfer = Activity::UpdateTransfer.execute!(
@state.transfer.id,
Transfer::Status::COLLECTION_SUCCEEDED,
)
when Transaction::Status::FAILED
@state.transfer = Activity::UpdateTransfer.execute!(
@state.transfer.id,
Transfer::Status::COLLECTION_FAILED,
)
end
end
end
end
end
endThis module is not an Activity and not a Child Workflow. It is orchestration logic that runs inside the workflow context.
Composing Modules in a Workflow
Once all the modules have been defined, they can be used to build up different workflows like LEGO® bricks. Looking back to our use case of paying international contractors and employer of record (EOR) employees, we want to compose those workflows to move funds through all the necessary steps until it finally reaches the correct person. In the following diagram, you’ll see that the flow of funds is almost identical.
We will be able to easily compose these workflows using our shared modules.
class ContractorTransferWorkflow < Temporalio::Workflow::Definition
include Workflow::Module::Collections
include Workflow::Module::ProviderFunding
include Workflow::Module::LocalDisbursement
def execute(transfer_id)
transfer = Activity::FindTransfer.execute!(transfer_id)
@state = Transfer::WorkflowState.new(transfer:)
module_collections_execute!
module_provider_funding_execute!
module_local_disbursement_execute!
return @state
end
end
class EmployeeTransferWorkflow < Temporalio::Workflow::Definition
include Workflow::Module::Collections
include Workflow::Module::ProviderFunding
include Workflow::Module::LocalEntityFunding
include Workflow::Module::LocalDisbursement
def execute(transfer_id)
transfer = Activity::FindTransfer.execute!(transfer_id)
@state = Transfer::WorkflowState.new(transfer:)
module_collections_execute!
module_provider_funding_execute!
module_local_entity_funding_execute!
module_local_disbursement_execute!
return @state
end
end
Now the workflow reads like a domain-level story, not a tangle of Temporal primitives. This also opens the door for the team to quickly integrate new payment flows without the added overhead of rebuilding the foundational modules.
Additional Considerations
Determinism
Because modules run inside the workflow, all Temporal determinism rules apply:
No non-deterministic Ruby code
No random numbers without Temporal APIs
No direct IO calls
The good news: Modules don’t make the code less deterministic; they only change how you organize code.
When to Use Modules in Temporal
Use them when:
You need reusable orchestration logic
Multiple workflows share timing/signal/state patterns
Activities are too low-level and Child Workflows are too heavy
Avoid them when:
You need isolation or independent scaling → use Child Workflows
You only need a single side-effectful operation → use an Activity
Why Not Child Workflows?
Workflow Modules differ from Child Workflows in key ways:
They also solve entirely different problems. A Child Workflow helps scale a Workflow by allowing it to offload processes into separate, isolated executions, but modules help improve the developer experience through code organization and modularity.
Final Thoughts
Temporal gives us powerful primitives, but it doesn’t dictate how we should structure orchestration code. By introducing Workflow Modules, we created a middle abstraction layer that matches how our domain actually works. They’re just Ruby Concerns, but, in practice, they’ve become the backbone of how we build composable, readable, and maintainable workflows. If you’re using Temporal in Ruby and find yourself copy-pasting orchestration logic, consider extracting workflow modules. Your future self (and teammates) will thank you.
Do you want to build products that help entrepreneurs and small businesses grow with confidence? We’re hiring across our Technology teams. Come build with us! Check out our Careers page.









