-
Notifications
You must be signed in to change notification settings - Fork 805
Triggers with more than 3 arguments
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.
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 availableYou can use C# tuples to group multiple parameters into a single parameter, effectively allowing you to pass any number of values.
// 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));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));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"));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"));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));// 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);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);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);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);
});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
}
}- 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.
- 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.
- Serialization: Tuples may not serialize well with some serializers. Consider using classes for data that needs to be serialized.
- Debugging: Very complex tuples can be harder to debug. Use named tuples and consider breaking them into smaller pieces.
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.