Skip to content

Triggers with more than 3 arguments

Mike Clift edited this page Nov 20, 2025 · 1 revision

Parameterized Triggers with More Than Three Arguments Using Tuples

Overview

Stateless provides built-in support for parameterized triggers with up to three type arguments using TriggerWithParameters<TArg0>, TriggerWithParameters<TArg0, TArg1>, and TriggerWithParameters<TArg0, TArg1, TArg2>. However, when you need more than three parameters, you can use C# tuples to work around this limitation.

This approach allows you to pass multiple values as a single tuple parameter while maintaining type safety and readability.

The Problem

Currently, Stateless only provides built-in support for triggers with up to three parameters:

// Built-in support for 1-3 parameters
var trigger1 = stateMachine.SetTriggerParameters<string>(Trigger.Action);
var trigger2 = stateMachine.SetTriggerParameters<string, int>(Trigger.Action);
var trigger3 = stateMachine.SetTriggerParameters<string, int, bool>(Trigger.Action);

// No built-in support for 4+ parameters
// var trigger4 = stateMachine.SetTriggerParameters<string, int, bool, DateTime>(Trigger.Action); // ❌ Not available

Solution: Using Tuples

You can use C# tuples to group multiple parameters into a single parameter, effectively allowing you to pass any number of values.

Basic Tuple Usage

// Define a trigger that takes a tuple with 4 parameters
var complexTrigger = stateMachine.SetTriggerParameters<(string Name, int Age, bool IsActive, DateTime CreatedDate)>(Trigger.ComplexAction);

// Configure the state machine
stateMachine.Configure(State.Waiting)
    .OnEntryFrom(complexTrigger, data => 
    {
        Console.WriteLine($"Name: {data.Name}, Age: {data.Age}, Active: {data.IsActive}, Created: {data.CreatedDate}");
    });

// Fire the trigger with tuple data
stateMachine.Fire(complexTrigger, ("John Doe", 30, true, DateTime.Now));

Using ValueTuple with Named Parameters

C# value tuples with named parameters provide excellent readability:

// Define a trigger with named tuple parameters
var userTrigger = stateMachine.SetTriggerParameters<(string Username, string Email, int UserId, bool IsAdmin, DateTime LastLogin)>(Trigger.UserAction);

stateMachine.Configure(State.Processing)
    .OnEntryFrom(userTrigger, userData => 
    {
        Console.WriteLine($"Processing user: {userData.Username} (ID: {userData.UserId})");
        Console.WriteLine($"Email: {userData.Email}, Admin: {userData.IsAdmin}");
        Console.WriteLine($"Last login: {userData.LastLogin}");
    });

// Fire with named tuple
stateMachine.Fire(userTrigger, (Username: "johndoe", Email: "john@example.com", UserId: 123, IsAdmin: false, LastLogin: DateTime.Now));

Using Tuple with Guard Conditions

You can also use tuples with guard conditions in PermitIf:

var validationTrigger = stateMachine.SetTriggerParameters<(string Input, int MinLength, bool RequireSpecialChars, string ErrorMessage)>(Trigger.Validate);

stateMachine.Configure(State.Validating)
    .PermitIf(validationTrigger, State.Valid, 
        data => data.Input.Length >= data.MinLength && 
                (!data.RequireSpecialChars || data.Input.Any(c => !char.IsLetterOrDigit(c))))
    .PermitIf(validationTrigger, State.Invalid, 
        data => data.Input.Length < data.MinLength || 
                (data.RequireSpecialChars && data.Input.All(char.IsLetterOrDigit)));

// Fire with validation data
stateMachine.Fire(validationTrigger, (Input: "password123!", MinLength: 8, RequireSpecialChars: true, ErrorMessage: "Invalid password"));

Using Tuple with Dynamic State Transitions

Tuples work well with dynamic state transitions:

var routingTrigger = stateMachine.SetTriggerParameters<(string Route, int Priority, bool IsUrgent, string Destination)>(Trigger.Route);

stateMachine.Configure(State.Routing)
    .PermitDynamic(routingTrigger, data => 
    {
        if (data.IsUrgent && data.Priority > 5)
            return State.HighPriority;
        else if (data.Route == "internal")
            return State.Internal;
        else
            return State.Standard;
    });

// Fire with routing data
stateMachine.Fire(routingTrigger, (Route: "external", Priority: 7, IsUrgent: true, Destination: "support"));

Using Tuple with Async Operations

Tuples work seamlessly with async operations:

var asyncTrigger = stateMachine.SetTriggerParameters<(string FilePath, int RetryCount, bool Overwrite, CancellationToken Token)>(Trigger.ProcessFile);

stateMachine.Configure(State.Processing)
    .OnEntryFromAsync(asyncTrigger, async data => 
    {
        await ProcessFileAsync(data.FilePath, data.Overwrite, data.Token);
        if (data.RetryCount > 0)
        {
            Console.WriteLine($"Retry attempt {data.RetryCount}");
        }
    });

// Fire with async data
using var cts = new CancellationTokenSource();
await stateMachine.FireAsync(asyncTrigger, (FilePath: "data.txt", RetryCount: 3, Overwrite: true, Token: cts.Token));

Best Practices

1. Use Named Tuples for Readability

// Good: Named tuple parameters
var trigger = stateMachine.SetTriggerParameters<(string Name, int Age, bool IsActive)>(Trigger.Action);

// Less readable: Unnamed tuple
var trigger = stateMachine.SetTriggerParameters<(string, int, bool)>(Trigger.Action);

2. Create Type Aliases for Complex Tuples

For frequently used tuple combinations, consider creating type aliases:

// Define a type alias for user data
using UserData = (string Username, string Email, int UserId, bool IsAdmin, DateTime LastLogin);

// Use the alias
var userTrigger = stateMachine.SetTriggerParameters<UserData>(Trigger.UserAction);

3. Use Records for Complex Data Structures

For very complex scenarios, consider using records instead of tuples:

public record UserInfo(string Username, string Email, int UserId, bool IsAdmin, DateTime LastLogin);

var userTrigger = stateMachine.SetTriggerParameters<UserInfo>(Trigger.UserAction);

4. Validate Tuple Data in Entry Actions

stateMachine.Configure(State.Processing)
    .OnEntryFrom(userTrigger, userData => 
    {
        // Validate the tuple data
        if (string.IsNullOrEmpty(userData.Username))
            throw new ArgumentException("Username cannot be empty");
        
        if (userData.UserId <= 0)
            throw new ArgumentException("User ID must be positive");
            
        // Process the data
        ProcessUser(userData);
    });

Complete Example

Here's a complete example showing how to use tuples for a complex workflow:

public enum State { Idle, Processing, Completed, Failed }
public enum Trigger { StartProcess, Complete, Fail }

public class WorkflowProcessor
{
    private readonly StateMachine<State, Trigger> _stateMachine;
    private readonly StateMachine<State, Trigger>.TriggerWithParameters<(string JobId, int Priority, bool IsUrgent, string Data, DateTime Deadline)> _startTrigger;

    public WorkflowProcessor()
    {
        _stateMachine = new StateMachine<State, Trigger>(State.Idle);
        
        // Define trigger with tuple containing 5 parameters
        _startTrigger = _stateMachine.SetTriggerParameters<(string JobId, int Priority, bool IsUrgent, string Data, DateTime Deadline)>(Trigger.StartProcess);

        ConfigureStateMachine();
    }

    private void ConfigureStateMachine()
    {
        _stateMachine.Configure(State.Idle)
            .OnEntryFrom(_startTrigger, jobData => 
            {
                Console.WriteLine($"Starting job: {jobData.JobId}");
                Console.WriteLine($"Priority: {jobData.Priority}, Urgent: {jobData.IsUrgent}");
                Console.WriteLine($"Deadline: {jobData.Deadline}");
                
                // Process the job data
                ProcessJob(jobData);
            })
            .PermitIf(_startTrigger, State.Processing, 
                jobData => !string.IsNullOrEmpty(jobData.JobId) && jobData.Deadline > DateTime.Now)
            .PermitIf(_startTrigger, State.Failed, 
                jobData => string.IsNullOrEmpty(jobData.JobId) || jobData.Deadline <= DateTime.Now);

        _stateMachine.Configure(State.Processing)
            .Permit(Trigger.Complete, State.Completed)
            .Permit(Trigger.Fail, State.Failed);
    }

    public void StartJob(string jobId, int priority, bool isUrgent, string data, DateTime deadline)
    {
        _stateMachine.Fire(_startTrigger, (JobId: jobId, Priority: priority, IsUrgent: isUrgent, Data: data, Deadline: deadline));
    }

    private void ProcessJob((string JobId, int Priority, bool IsUrgent, string Data, DateTime Deadline) jobData)
    {
        // Implementation here
    }
}

Performance Considerations

  • Value Types: Tuples are value types, so they're copied when passed around. For very large tuples, consider using a reference type (class or record).
  • Memory Allocation: Small tuples (up to 8 elements) are optimized by the runtime and don't cause heap allocations.
  • Boxing: Avoid boxing tuples unnecessarily, especially in performance-critical code.

Limitations

  1. Tuple Size: While there's no hard limit, very large tuples (10+ elements) become unwieldy and should be replaced with a proper class or record.
  2. Serialization: Tuples may not serialize well with some serializers. Consider using classes for data that needs to be serialized.
  3. Debugging: Very complex tuples can be harder to debug. Use named tuples and consider breaking them into smaller pieces.

Conclusion

Using tuples for parameterized triggers with more than three arguments is a clean, type-safe solution that leverages C# language features. This approach provides:

  • Type Safety: Compile-time checking of parameter types
  • Readability: Named tuples make the code self-documenting
  • Flexibility: Support for any number of parameters
  • Performance: Efficient value type semantics for small tuples

This solution addresses the limitation mentioned in issue #598 without requiring changes to the Stateless library itself.