Skip to content

Commit 44e2900

Browse files
committed
Clarified the Guard on transitions behaviour in the documentation, samples and with the static analysis rules.
1 parent 0a52708 commit 44e2900

6 files changed

Lines changed: 170 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
2626
- Added conceptual pages section for non-feature topics
2727
- Improved consistency across all documentation pages
2828
- Added comprehensive index page with links to all documentation
29+
- **Clarified first-match semantics** - Made it explicit that transitions evaluate in order and first match wins
30+
- Replaced `Guard(() => true)` catch-all pattern with clearer "no guard" approach
2931
- **🚀 NuGet publishing** - Set up automated Trusted Publishing with GitHub Actions
3032
- Configured OIDC-based authentication (no long-lived API keys)
3133
- Added comprehensive PUBLISHING.md guide
3234
- Fixed symbol package generation for analyzer projects
3335
- Added proper package descriptions for all projects
3436
- Separate handling for analyzer vs library packages
3537

38+
### Added
39+
- **🔍 Enhanced static analysis** - New build-time validations for guard patterns
40+
- **Error**: Unguarded transition appearing before other transitions for same trigger (makes subsequent transitions unreachable)
41+
- **Warning**: Multiple guarded transitions on same trigger (reminder about first-match semantics)
42+
- Helps prevent common guard ordering mistakes
43+
- Clear error messages explain first-match behavior
44+
3645
### Fixed
3746
- **📦 Analyzer packaging** - Fixed NuGet pack errors for Roslyn analyzer project
3847
- Disabled symbol package generation for FunctionalStateMachine.Diagrams

docs/guards.md

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
Guards let you choose between multiple transitions for the same trigger based on state, data, or trigger properties. They encode business rules directly in your transitions.
44

5+
> **🎯 First-Match Semantics**: Transitions are evaluated **in order**. The **first matching transition wins** and executes immediately. Subsequent transitions for the same trigger are never checked.
6+
57
## Table of Contents
68

79
1. [Why Use Guards](#why-use-guards)
@@ -62,50 +64,53 @@ var (newState, newData, commands) = machine.Fire(
6264
**How it works:**
6365
- Guard evaluates the predicate `data.CreditScore >= 700`
6466
- If **true**, the transition executes
65-
- If **false**, this transition is skipped
67+
- If **false**, this transition is skipped and the next transition for the same trigger is checked
6668

67-
**What happens if the guard fails?** If no other transition handles this trigger, it's **unhandled** and throws an exception.
69+
**What happens if the guard fails?** If no other transition handles this trigger, it's **unhandled** and throws an exception (unless you've configured `.OnUnhandled()`).
6870

6971
---
7072

7173
## Multiple Guarded Transitions
7274

73-
Handle different cases by defining multiple transitions for the same trigger:
75+
Handle different cases by defining multiple transitions for the same trigger with **first-match semantics**:
7476

7577
```csharp
7678
var machine = StateMachine<LoanState, LoanTrigger, LoanData, LoanCommand>.Create()
7779
.StartWith(LoanState.Application)
7880
.For(LoanState.Application)
81+
// ⚠️ ORDER MATTERS: First matching guard wins!
7982
.On<LoanTrigger.Submit>()
80-
.Guard(data => data.CreditScore >= 700) // First guard
83+
.Guard(data => data.CreditScore >= 700) // Checked first
8184
.Execute(() => new LoanCommand.SendApproval())
8285
.TransitionTo(LoanState.Approved)
8386
.On<LoanTrigger.Submit>()
84-
.Guard(data => data.CreditScore < 700) // Second guard
87+
.Guard(data => data.CreditScore < 700) // Checked second
8588
.Execute(() => new LoanCommand.SendRejection())
8689
.TransitionTo(LoanState.Rejected)
8790
.Build();
8891
```
8992

90-
**Evaluation order:**
91-
Guards are evaluated **in the order you define them**. The **first matching guard wins**.
93+
**⚠️ First-Match Evaluation:**
94+
- Transitions are evaluated **in the order you define them**
95+
- The **first matching guard wins** and executes
96+
- Subsequent transitions are **never checked** (short-circuit evaluation)
9297

9398
```csharp
9499
// CreditScore = 650
95100
var (newState, _, commands) = machine.Fire(
96101
new LoanTrigger.Submit(),
97102
LoanState.Application,
98103
new LoanData(50000, 650));
99-
// First guard fails (650 < 700 is false)
100-
// Second guard passes (650 < 700 is true) ✅
104+
// First guard fails (650 >= 700 is false) → check next
105+
// Second guard passes (650 < 700 is true) → EXECUTE, STOP
101106
// newState == LoanState.Rejected
102107
```
103108

104109
---
105110

106-
## Guard with Multiple Conditions
111+
## Catch-All Pattern (No Guard)
107112

108-
Guards can check multiple properties:
113+
For the final "else" case, **omit the guard entirely** instead of using `Guard(data => true)`:
109114

110115
```csharp
111116
public sealed record LoanData(decimal Amount, int CreditScore, bool HasCollateral);
@@ -125,15 +130,19 @@ var machine = StateMachine<LoanState, LoanTrigger, LoanData, LoanCommand>.Create
125130
.Execute(() => new LoanCommand.SendApproval())
126131
.TransitionTo(LoanState.Approved)
127132

128-
// Everything else rejected
129-
.On<LoanTrigger.Submit>()
130-
.Guard(data => true) // Catch-all guard
133+
// Catch-all: everything else rejected (NO GUARD)
134+
.On<LoanTrigger.Submit>() // ← No guard = always matches
131135
.Execute(() => new LoanCommand.SendRejection())
132136
.TransitionTo(LoanState.Rejected)
133137
.Build();
134138
```
135139

136-
**Pattern:** Use a catch-all guard (`data => true`) as the last option to ensure all cases are handled.
140+
**Why no guard?**
141+
- A transition with no guard **always matches** (same as `Guard(data => true)`)
142+
- Clearer intent: "this is the default case"
143+
- More idiomatic and concise
144+
145+
**Pattern:** Order your transitions from **most specific to least specific**, with the catch-all (no guard) last.
137146

138147
---
139148

@@ -274,9 +283,8 @@ var machine = StateMachine<ATMState, ATMTrigger, ATMData, ATMCommand>.Create()
274283
.Execute(() => new ATMCommand.ShowMessage("Insufficient funds"))
275284
.TransitionTo(ATMState.InsufficientFunds)
276285

277-
// Guard 3: Successful withdrawal (catch-all)
278-
.On<ATMTrigger.ConfirmAmount>()
279-
.Guard((data, trigger) => true) // If we got here, all checks passed
286+
// Guard 3: Successful withdrawal (no guard = catch-all)
287+
.On<ATMTrigger.ConfirmAmount>() // No guard - if we got here, all checks passed
280288
.ModifyData((data, trigger) => data with
281289
{
282290
Balance = data.Balance - trigger.Amount,
@@ -337,29 +345,33 @@ var (state3, _, commands3) = machine.Fire(
337345
```
338346

339347
**What's happening:**
340-
1. Three guards check conditions in order: daily limit, insufficient funds, success
341-
2. Each guard routes to a different state based on the condition
342-
3. The success guard modifies data and emits commands
343-
4. All guards use the same trigger but produce different outcomes
348+
1. ⚠️ **First-match semantics**: Transitions are evaluated in order
349+
2. First guard checks daily limit, if it passes → go to DailyLimitReached, STOP
350+
3. Second guard checks insufficient funds, if it passes → go to InsufficientFunds, STOP
351+
4. Third transition has no guard (catch-all) → always executes if we reach it
352+
5. The catch-all modifies data, emits commands, and transitions to Dispensing
344353

345354
---
346355

347356
## Best Practices
348357

358+
**⚠️ Remember first-match semantics**
359+
Only the **first matching transition executes**. Order matters!
360+
349361
**Order guards from most specific to most general**
350-
Put stricter conditions first, catch-all guards last.
362+
Put stricter conditions first, catch-all (no guard) last.
351363

352-
**Use a catch-all guard for completeness**
353-
`Guard(data => true)` ensures all cases are handled.
364+
**Omit the guard for catch-all cases**
365+
Use no guard instead of `Guard(data => true)` for the final "else" case. It's clearer and more idiomatic.
354366

355367
**Keep guards pure**
356368
Don't perform I/O or side effects in guard predicates. Only inspect data.
357369

358370
**Consider If/ElseIf/Else for single-state variations**
359371
Use guards when you need to go to different states. Use If/Else when you stay in the same state.
360372

361-
**Avoid overlapping guards without intention**
362-
If two guards can both be true, only the first will execute.
373+
**Avoid unguarded transitions before other transitions**
374+
An unguarded transition matches everything, making subsequent transitions for the same trigger unreachable. The build-time analyzer will detect this error.
363375

364376
---
365377

docs/immediate-transitions.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,7 @@ var machine = StateMachine<ProcessState, ProcessTrigger, ProcessData, ProcessCom
141141
.Guard(data => data.Score < 30) // Very low score
142142
.Execute(() => new ProcessCommand.SendRejection())
143143
.TransitionTo(ProcessState.Rejected)
144-
.Immediately()
145-
.Guard(data => true) // Catch-all
144+
.Immediately() // Catch-all: no guard means "always execute"
146145
.Execute(() => new ProcessCommand.RequestReview())
147146
.TransitionTo(ProcessState.NeedsReview)
148147
.Done()
@@ -489,8 +488,7 @@ Not every state needs an immediate transition. Use triggers for external events.
489488
.Immediately()
490489
.Guard(data => data.Condition2)
491490
.TransitionTo(State.Path2)
492-
.Immediately()
493-
.Guard(data => true)
491+
.Immediately() // No guard = catch-all
494492
.TransitionTo(State.DefaultPath)
495493
.Done()
496494
```

src/FunctionalStateMachine.Core/StateMachineAnalysis.cs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,8 @@ private static void DetectCycle(
235235
}
236236

237237
/// <summary>
238-
/// Detect multiple unguarded transitions for the same trigger (ambiguous routing).
238+
/// Detect multiple transitions for the same trigger and unreachable transitions.
239+
/// Uses first-match semantics: only the first matching transition executes.
239240
/// </summary>
240241
private static void AnalyzeAmbiguousTransitions(
241242
IReadOnlyDictionary<TState, StateMachine<TState, TTrigger, TData, TCommand>.StateDefinition> states,
@@ -249,6 +250,33 @@ private static void AnalyzeAmbiguousTransitions(
249250
{
250251
var triggerKey = transitionKvp.Key;
251252
var transitions = transitionKvp.Value;
253+
var triggerType = GetTriggerTypeName(triggerKey);
254+
255+
// Error: Multiple transitions on the same trigger
256+
if (transitions.Count > 1)
257+
{
258+
// Check if there's an unguarded transition before the last one
259+
for (int i = 0; i < transitions.Count - 1; i++)
260+
{
261+
if (transitions[i].Guard == null)
262+
{
263+
result.AddError(
264+
$"State '{state}' has an unguarded transition for trigger '{triggerType}' at position {i + 1}, " +
265+
"making subsequent transitions unreachable. Only the first matching transition executes (first-match semantics). " +
266+
"Either add a guard to this transition or remove subsequent transitions for this trigger.");
267+
}
268+
}
269+
270+
// Warning: Multiple guarded transitions (might be intentional routing, but worth noting)
271+
if (transitions.All(t => t.Guard != null))
272+
{
273+
result.AddWarning(
274+
$"State '{state}' has {transitions.Count} guarded transitions for trigger '{triggerType}'. " +
275+
"Only the first matching guard will execute (first-match semantics). " +
276+
"Ensure guards are ordered from most specific to least specific.");
277+
}
278+
}
279+
252280
// Find unguarded transitions
253281
var unguardedTransitions = transitions.Where(t => t.Guard == null).ToList();
254282

@@ -262,11 +290,10 @@ private static void AnalyzeAmbiguousTransitions(
262290
// Error if they go to different states (ambiguous routing)
263291
if (targets.Count > 1)
264292
{
265-
var triggerType = GetTriggerTypeName(triggerKey);
266293
result.AddError(
267-
$"State '{state}' has ambiguous transitions for trigger '{triggerType}' leading to different states: " +
268-
string.Join(", ", targets.Select(t => $"'{t}'")) +
269-
". Add guards or consolidate transitions to resolve the ambiguity.");
294+
$"State '{state}' has {unguardedTransitions.Count} unguarded transitions for trigger '{triggerType}' " +
295+
$"leading to different states: {string.Join(", ", targets.Select(t => $"'{t}'"))}. " +
296+
"Add guards or consolidate transitions to resolve the ambiguity.");
270297
}
271298
}
272299
}

test/FunctionalStateMachine.Core.Tests/ConfigurationExtensionOverloadTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ public void Guard_Transition_LabelFuncStateDataTriggerBool_EvaluatesGuard()
278278
.StartWith(State.Ready)
279279
.For(State.Ready)
280280
.On(Trigger.Go)
281-
.Guard("FullCheck", (State state, Data data, Trigger trigger) => true)
281+
.Guard("FullCheck", (State state, Data data, Trigger trigger) => state == State.Ready)
282282
.TransitionTo(State.Done)
283283
.For(State.Done)
284284
.Build();
@@ -393,7 +393,7 @@ public void Guard_NoDataTransition_LabelFuncStateTriggerBool_EvaluatesGuard()
393393
.StartWith(State.Ready)
394394
.For(State.Ready)
395395
.On(Trigger.Go)
396-
.Guard("FullCheck", (State state, Trigger trigger) => true)
396+
.Guard("FullCheck", (State state, Trigger trigger) => state == State.Ready && trigger == Trigger.Go)
397397
.TransitionTo(State.Done)
398398
.Done()
399399
.For(State.Done)

test/FunctionalStateMachine.Core.Tests/StateMachineAnalysisTests.cs

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,15 @@ public void Validate_DetectsAmbiguousTransitions()
137137
{
138138
var exception = Assert.Throws<InvalidOperationException>(() =>
139139
{
140-
// Multiple transitions are not allowed
140+
// Multiple unguarded transitions are not allowed
141141
var machine = StateMachine<State, Trigger, Data, CommandBase>.Create()
142142
.StartWith(State.A)
143143
.For(State.A)
144144
.On<Trigger.T1Trigger>()
145145
.TransitionTo(State.B)
146146
.Execute(() => new Command.Noop())
147147
.On<Trigger.T1Trigger>()
148-
.TransitionTo(State.C) // Multiple transitions
148+
.TransitionTo(State.C) // Multiple unguarded transitions
149149
.Execute(() => new Command.Noop())
150150
.For(State.B)
151151
.On<Trigger.T1Trigger>()
@@ -156,7 +156,8 @@ public void Validate_DetectsAmbiguousTransitions()
156156
.Build();
157157
});
158158

159-
Assert.Contains("ambiguous", exception.Message, StringComparison.OrdinalIgnoreCase);
159+
Assert.Contains("unguarded", exception.Message, StringComparison.OrdinalIgnoreCase);
160+
Assert.Contains("unreachable", exception.Message, StringComparison.OrdinalIgnoreCase);
160161
}
161162

162163
[Fact]
@@ -379,6 +380,88 @@ public void Validate_MarksParentStatesReachable_WhenImmediateTransitionTargetsCh
379380
Assert.NotNull(machine);
380381
}
381382

383+
[Fact]
384+
public void Validate_DetectsUnguardedTransitionBeforeOtherTransitions()
385+
{
386+
// Unguarded transition before other transitions makes them unreachable
387+
var exception = Assert.Throws<InvalidOperationException>(() =>
388+
{
389+
StateMachine<State, Trigger, Data, CommandBase>.Create()
390+
.StartWith(State.A)
391+
.For(State.A)
392+
.On<Trigger.T1Trigger>()
393+
.TransitionTo(State.B) // No guard - always matches
394+
.Execute(() => new Command.Noop())
395+
.On<Trigger.T1Trigger>() // This is unreachable!
396+
.Guard(data => data.Value > 10)
397+
.TransitionTo(State.C)
398+
.Execute(() => new Command.Noop())
399+
.For(State.B)
400+
.On<Trigger.T1Trigger>()
401+
.TransitionTo(State.A)
402+
.For(State.C)
403+
.On<Trigger.T1Trigger>()
404+
.TransitionTo(State.A)
405+
.Build();
406+
});
407+
408+
Assert.Contains("unguarded transition", exception.Message, StringComparison.OrdinalIgnoreCase);
409+
Assert.Contains("unreachable", exception.Message, StringComparison.OrdinalIgnoreCase);
410+
}
411+
412+
[Fact]
413+
public void Validate_AllowsUnguardedTransitionAsLast()
414+
{
415+
// Unguarded transition as the last one is the catch-all pattern
416+
var machine = StateMachine<State, Trigger, Data, CommandBase>.Create()
417+
.StartWith(State.A)
418+
.For(State.A)
419+
.On<Trigger.T1Trigger>()
420+
.Guard(data => data.Value > 10)
421+
.TransitionTo(State.B)
422+
.Execute(() => new Command.Noop())
423+
.On<Trigger.T1Trigger>() // Catch-all: no guard, last position
424+
.TransitionTo(State.C)
425+
.Execute(() => new Command.Noop())
426+
.For(State.B)
427+
.On<Trigger.T1Trigger>()
428+
.TransitionTo(State.A)
429+
.For(State.C)
430+
.On<Trigger.T1Trigger>()
431+
.TransitionTo(State.A)
432+
.Build();
433+
434+
Assert.NotNull(machine);
435+
}
436+
437+
[Fact]
438+
public void Validate_WarnsAboutMultipleGuardedTransitions()
439+
{
440+
// Multiple guarded transitions should produce a warning (but not error)
441+
// This is allowed but worth noting to users
442+
var machine = StateMachine<State, Trigger, Data, CommandBase>.Create()
443+
.StartWith(State.A)
444+
.For(State.A)
445+
.On<Trigger.T1Trigger>()
446+
.Guard(data => data.Value > 10)
447+
.TransitionTo(State.B)
448+
.Execute(() => new Command.Noop())
449+
.On<Trigger.T1Trigger>()
450+
.Guard(data => data.Value <= 10)
451+
.TransitionTo(State.C)
452+
.Execute(() => new Command.Noop())
453+
.For(State.B)
454+
.On<Trigger.T1Trigger>()
455+
.TransitionTo(State.A)
456+
.For(State.C)
457+
.On<Trigger.T1Trigger>()
458+
.TransitionTo(State.A)
459+
.Build();
460+
461+
// Should build successfully (warning, not error)
462+
Assert.NotNull(machine);
463+
}
464+
382465
[Fact]
383466
public void Validate_MarksGrandparentStatesReachable_WhenTransitionTargetsNestedChild()
384467
{

0 commit comments

Comments
 (0)