Skip to content

Deterministic Output

This is the final chapter of Part 2. When integrating all the symbol analysis, type extraction, and code generation techniques learned so far, one principle permeates the whole — the same input must always produce the same output. For the incremental caching covered in Chapter 4 to work correctly, this deterministic output must be guaranteed. If this principle is broken, incremental builds, source control, and CI/CD are all affected.

  1. Understanding the importance of deterministic output
    • Impact on incremental builds, source control, and CI/CD
  2. Reason for using the global:: prefix
    • Preventing namespace conflicts with user code
  3. Techniques for ensuring consistent output
    • Excluding timestamps, sorting collections, consistent formatting

Deterministic output means always producing the same output for the same input.

Deterministic Output
====================
Input A -> Output X (always)
Input B -> Output Y (always)
Non-Deterministic Output
========================
Input A -> Output X (sometimes)
Input A -> Output X' (other times)

Deterministic Output
====================
Build 1: UserRepository -> UserRepositoryObservable.g.cs (content X)
Build 2: UserRepository (no changes) -> Cache used (build skipped)
Non-Deterministic Output
========================
Build 1: UserRepository -> UserRepositoryObservable.g.cs (content X)
Build 2: UserRepository (no changes) -> Content X' (different) -> Rebuild
Deterministic Output
====================
git status: No changes (generated files are identical)
Non-Deterministic Output
========================
git status: Files changed (content is semantically identical but different)
-> Unnecessary commits occur
Deterministic Output
====================
Same results in any environment
-> Improved CI/CD reliability
Non-Deterministic Output
========================
Different results depending on the environment
-> "It worked on my machine..." problem

// User code
namespace MyApp
{
public class System { } // A class named System!
}
// Generated code (without global::)
namespace MyApp
{
public class UserPipeline
{
System.ArgumentNullException.ThrowIfNull(x);
// ❌ Error: MyApp.System has no ArgumentNullException!
}
}
// Generated code (with global::)
namespace MyApp
{
public class UserPipeline
{
global::System.ArgumentNullException.ThrowIfNull(x);
// ✅ Correctly references the System namespace
}
}
// ✅ global:: prefix for all external types
sb.AppendLine(" global::System.ArgumentNullException.ThrowIfNull(value);");
sb.AppendLine(" global::System.Diagnostics.Activity.Current = null;");
sb.AppendLine(" return global::LanguageExt.Unit.Default;");
// Using SymbolDisplayFormats.GlobalQualifiedFormat
string typeName = type.ToDisplayString(SymbolDisplayFormats.GlobalQualifiedFormat);
// -> "global::System.Collections.Generic.List<global::MyApp.User>"
// Used in generated code
sb.AppendLine($" {typeName} result = ...;");

// ❌ Non-deterministic
sb.AppendLine($"// Generated at {DateTime.Now}");
// ✅ Deterministic
sb.AppendLine("// <auto-generated/>");
// ❌ Non-deterministic
sb.AppendLine($"// ID: {Guid.NewGuid()}");
// ✅ Deterministic
// GUID unnecessary - remove
// ❌ Non-deterministic
string path = Environment.GetEnvironmentVariable("PATH");
sb.AppendLine($"// Path: {path}");
// ✅ Deterministic
// Do not use environment variables
// ❌ Non-deterministic (order not guaranteed)
var methods = classSymbol.GetMembers()
.OfType<IMethodSymbol>()
.ToList();
// ✅ Deterministic (sorted order)
var methods = classSymbol.GetMembers()
.OfType<IMethodSymbol>()
.OrderBy(m => m.Name) // Sort by name
.ThenBy(m => m.Parameters.Length) // Sort by parameter count
.ToList();

// ❌ Inconsistent
sb.Append("public class ");
sb.Append(className); // might sometimes use AppendLine
// ✅ Consistent
sb.Append("public class ")
.Append(className)
.AppendLine()
.AppendLine("{");
// ❌ Mixed (tabs and spaces)
sb.AppendLine("\tprivate int _id;"); // tab
sb.AppendLine(" private string _name;"); // spaces
// ✅ Consistent (spaces only)
sb.AppendLine(" private int _id;");
sb.AppendLine(" private string _name;");

Terminal window
# First build
dotnet build
cp Generated/MyClass.g.cs /tmp/first.cs
# Second build (without clean)
dotnet build
cp Generated/MyClass.g.cs /tmp/second.cs
# Compare
diff /tmp/first.cs /tmp/second.cs
# There should be no differences
[Fact]
public void Generated_Code_Should_Be_Deterministic()
{
string input = """
[GenerateObservablePort]
public class UserRepository : IObservablePort
{
public FinT<IO, User> GetUserAsync(int id) => throw new();
}
""";
// Generate twice
string? output1 = _sut.Generate(input);
string? output2 = _sut.Generate(input);
// Must be identical
output1.ShouldBe(output2);
}
[Fact]
public Task Generated_Code_Should_Match_Snapshot()
{
string input = """
[GenerateObservablePort]
public class UserRepository : IObservablePort
{
public FinT<IO, User> GetUserAsync(int id) => throw new();
}
""";
string? actual = _sut.Generate(input);
// Compare with snapshot
return Verify(actual);
}
// Result saved to .verified.txt file
// If changed later, test fails -> check if the change was intentional

□ Use global:: prefix for all external types
□ Use SymbolDisplayFormat.FullyQualifiedFormat or custom format
□ Exclude runtime values such as timestamps and GUIDs
□ Do not use environment variables
□ Sort collection order
□ Consistent whitespace/indentation
□ Verify identical results when building twice

Here is a summary of the key principles for deterministic output.

ElementNon-DeterministicDeterministic
Type referenceSystem.Int32global::System.Int32
MetadataDateTime.NowExcluded
CollectionsUnordered.OrderBy()
WhitespaceMixedConsistent rules
VerificationNoneSnapshot tests

Q1: What problems occur when deterministic output breaks?

Section titled “Q1: What problems occur when deterministic output breaks?”

A: Three problems occur in cascade. First, cache is invalidated in incremental builds, causing a full rebuild every time. Second, meaningless diffs appear in source control, generating unnecessary commits. Third, the same code produces different results in CI/CD, breaking build reproducibility.

Q2: Does sorting with .OrderBy() affect performance?

Section titled “Q2: Does sorting with .OrderBy() affect performance?”

A: Source generators run at compile time, so sorting cost is included in build time. Since the number of methods in most classes is a few dozen or fewer, the sorting cost is negligible. In fact, not sorting and causing non-deterministic output invalidates the incremental build cache, resulting in a much greater performance loss.

Q3: What is the difference between SymbolDisplayFormat and manually adding the global:: prefix?

Section titled “Q3: What is the difference between SymbolDisplayFormat and manually adding the global:: prefix?”

A: Using SymbolDisplayFormat.FullyQualifiedFormat causes Roslyn to automatically generate the full path of a type including the global:: prefix. Manually hardcoding strings like "global::System.Int32" risks omission when types change. Functorium defines a custom SymbolDisplayFormats.GlobalQualifiedFormat to ensure consistency.


Part 2 has covered all the core concepts of source generators. In the next Part, we will learn how to handle complex cases encountered in practice, such as Primary Constructors, generic types, and collection types.

-> Part 3. Advanced