17 KiB
Implementation Guide
.NET 10 implementation patterns and best practices for Release Orchestrator modules.
Target Audience: Development team implementing Release Orchestrator modules Prerequisites: Familiarity with CLAUDE.md coding rules
Overview
This guide supplements the architecture documentation with .NET 10-specific implementation patterns required for all Release Orchestrator modules. These patterns ensure:
- Deterministic behavior for evidence reproducibility
- Testability through dependency injection
- Compliance with Stella Ops coding standards
- Performance and reliability
Code Quality Requirements
Compiler Configuration
All Release Orchestrator projects MUST enforce warnings as errors:
<!-- In Directory.Build.props or .csproj -->
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
Rationale: Warnings indicate potential bugs, regressions, or code quality drift. Treating them as errors prevents them from being ignored.
Determinism & Time Handling
TimeProvider Injection
Never use DateTime.UtcNow, DateTimeOffset.UtcNow, or DateTimeOffset.Now directly. Always inject TimeProvider.
// ❌ BAD - non-deterministic, hard to test
public class PromotionManager
{
public Promotion CreatePromotion(Guid releaseId, Guid targetEnvId)
{
return new Promotion
{
Id = Guid.NewGuid(),
ReleaseId = releaseId,
TargetEnvironmentId = targetEnvId,
RequestedAt = DateTimeOffset.UtcNow // ❌ Hard-coded time
};
}
}
// ✅ GOOD - injectable, testable, deterministic
public class PromotionManager
{
private readonly TimeProvider _timeProvider;
private readonly IGuidGenerator _guidGenerator;
public PromotionManager(TimeProvider timeProvider, IGuidGenerator guidGenerator)
{
_timeProvider = timeProvider;
_guidGenerator = guidGenerator;
}
public Promotion CreatePromotion(Guid releaseId, Guid targetEnvId)
{
return new Promotion
{
Id = _guidGenerator.NewGuid(),
ReleaseId = releaseId,
TargetEnvironmentId = targetEnvId,
RequestedAt = _timeProvider.GetUtcNow() // ✅ Injected, testable
};
}
}
Registration:
// Production: use system time
services.AddSingleton(TimeProvider.System);
// Testing: use manual time for deterministic tests
var manualTime = new ManualTimeProvider();
manualTime.SetUtcNow(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
services.AddSingleton<TimeProvider>(manualTime);
GUID Generation
Never use Guid.NewGuid() directly. Always inject IGuidGenerator.
// ❌ BAD
var releaseId = Guid.NewGuid();
// ✅ GOOD
var releaseId = _guidGenerator.NewGuid();
Interface:
public interface IGuidGenerator
{
Guid NewGuid();
}
// Production implementation
public sealed class SystemGuidGenerator : IGuidGenerator
{
public Guid NewGuid() => Guid.NewGuid();
}
// Deterministic test implementation
public sealed class SequentialGuidGenerator : IGuidGenerator
{
private int _counter;
public Guid NewGuid()
{
var bytes = new byte[16];
BitConverter.GetBytes(_counter++).CopyTo(bytes, 0);
return new Guid(bytes);
}
}
Async & Cancellation
CancellationToken Propagation
Always propagate CancellationToken through async call chains. Never use CancellationToken.None except at entry points where no token is available.
// ❌ BAD - ignores cancellation
public async Task<Promotion> ApprovePromotionAsync(Guid promotionId, Guid userId, CancellationToken ct)
{
var promotion = await _repository.GetByIdAsync(promotionId, CancellationToken.None); // ❌ Wrong
promotion.Approvals.Add(new Approval
{
ApproverId = userId,
ApprovedAt = _timeProvider.GetUtcNow()
});
await _repository.SaveAsync(promotion, CancellationToken.None); // ❌ Wrong
await Task.Delay(1000); // ❌ Missing ct
return promotion;
}
// ✅ GOOD - propagates cancellation
public async Task<Promotion> ApprovePromotionAsync(Guid promotionId, Guid userId, CancellationToken ct)
{
var promotion = await _repository.GetByIdAsync(promotionId, ct); // ✅ Propagated
promotion.Approvals.Add(new Approval
{
ApproverId = userId,
ApprovedAt = _timeProvider.GetUtcNow()
});
await _repository.SaveAsync(promotion, ct); // ✅ Propagated
await Task.Delay(1000, ct); // ✅ Cancellable
return promotion;
}
HTTP Client Usage
IHttpClientFactory for Connector Runtime
Never instantiate HttpClient directly. Always use IHttpClientFactory with configured timeouts and resilience policies.
// ❌ BAD - direct instantiation risks socket exhaustion
public class GitHubConnector
{
public async Task<string> GetCommitAsync(string sha)
{
using var client = new HttpClient(); // ❌ Socket exhaustion risk
var response = await client.GetAsync($"https://api.github.com/commits/{sha}");
return await response.Content.ReadAsStringAsync();
}
}
// ✅ GOOD - factory with resilience
public class GitHubConnector
{
private readonly IHttpClientFactory _httpClientFactory;
public GitHubConnector(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<string> GetCommitAsync(string sha, CancellationToken ct)
{
var client = _httpClientFactory.CreateClient("GitHub");
var response = await client.GetAsync($"/commits/{sha}", ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(ct);
}
}
Registration with resilience:
services.AddHttpClient("GitHub", client =>
{
client.BaseAddress = new Uri("https://api.github.com");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps/1.0");
})
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(1);
});
Culture & Formatting
Invariant Culture for Parsing
Always use CultureInfo.InvariantCulture for parsing and formatting dates, numbers, and any string that will be persisted, hashed, or compared.
// ❌ BAD - culture-sensitive
var percentage = double.Parse(input);
var formatted = value.ToString("P2");
var dateStr = date.ToString("yyyy-MM-dd");
// ✅ GOOD - invariant culture
var percentage = double.Parse(input, CultureInfo.InvariantCulture);
var formatted = value.ToString("P2", CultureInfo.InvariantCulture);
var dateStr = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
JSON Handling
RFC 8785 Canonical JSON for Evidence
For evidence packets and decision records that will be hashed or signed, use RFC 8785-compliant canonical JSON serialization.
// ❌ BAD - non-canonical JSON
var json = JsonSerializer.Serialize(decisionRecord, new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
var hash = ComputeHash(json); // ❌ Non-deterministic
// ✅ GOOD - use shared canonicalizer
var canonicalJson = CanonicalJsonSerializer.Serialize(decisionRecord);
var hash = ComputeHash(canonicalJson); // ✅ Deterministic
Canonical JSON Requirements:
- Keys sorted alphabetically
- Minimal escaping per RFC 8785 spec
- No exponent notation for numbers
- No trailing/leading zeros
- No whitespace
Database Interaction
DateTimeOffset for PostgreSQL timestamptz
PostgreSQL timestamptz columns MUST be read and written as DateTimeOffset, not DateTime.
// ❌ BAD - loses offset information
await using var reader = await command.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var createdAt = reader.GetDateTime(reader.GetOrdinal("created_at")); // ❌ Loses offset
}
// ✅ GOOD - preserves offset
await using var reader = await command.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var createdAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")); // ✅ Correct
}
Insertion:
// ✅ Always use UTC DateTimeOffset
var createdAt = _timeProvider.GetUtcNow(); // Returns DateTimeOffset
await command.ExecuteNonQueryAsync(ct);
Hybrid Logical Clock (HLC) for Distributed Ordering
For distributed ordering and audit-safe sequencing, use IHybridLogicalClock from StellaOps.HybridLogicalClock.
When to use HLC:
- Promotion state transitions
- Workflow step execution ordering
- Deployment task sequencing
- Timeline event ordering
public class PromotionStateTransition
{
private readonly IHybridLogicalClock _hlc;
private readonly TimeProvider _timeProvider;
public async Task TransitionStateAsync(
Promotion promotion,
PromotionState newState,
CancellationToken ct)
{
var transition = new StateTransition
{
PromotionId = promotion.Id,
FromState = promotion.Status,
ToState = newState,
THlc = _hlc.Tick(), // ✅ Monotonic, skew-tolerant ordering
TsWall = _timeProvider.GetUtcNow(), // ✅ Informational timestamp
TransitionedBy = _currentUser.Id
};
await _repository.RecordTransitionAsync(transition, ct);
}
}
HLC State Persistence:
// Service startup
public async Task StartAsync(CancellationToken ct)
{
await _hlc.InitializeFromStateAsync(ct); // Restore monotonicity
}
// Service shutdown
public async Task StopAsync(CancellationToken ct)
{
await _hlc.PersistStateAsync(ct); // Persist HLC state
}
Configuration & Options
Options Validation at Startup
Use ValidateDataAnnotations() and ValidateOnStart() for all options classes.
// Options class
public sealed class PromotionManagerOptions
{
[Required]
[Range(1, 10)]
public int MaxConcurrentPromotions { get; set; } = 3;
[Required]
[Range(1, 3600)]
public int ApprovalExpirationSeconds { get; set; } = 1440;
}
// Registration with validation
services.AddOptions<PromotionManagerOptions>()
.Bind(configuration.GetSection("PromotionManager"))
.ValidateDataAnnotations()
.ValidateOnStart();
// Complex validation
public class PromotionManagerOptionsValidator : IValidateOptions<PromotionManagerOptions>
{
public ValidateOptionsResult Validate(string? name, PromotionManagerOptions options)
{
if (options.MaxConcurrentPromotions <= 0)
return ValidateOptionsResult.Fail("MaxConcurrentPromotions must be positive");
return ValidateOptionsResult.Success;
}
}
services.AddSingleton<IValidateOptions<PromotionManagerOptions>, PromotionManagerOptionsValidator>();
Immutability & Collections
Return Immutable Collections from Public APIs
Public APIs MUST return IReadOnlyList<T>, ImmutableArray<T>, or defensive copies. Never expose mutable backing stores.
// ❌ BAD - exposes mutable backing store
public class ReleaseManager
{
private readonly List<Component> _components = new();
public List<Component> Components => _components; // ❌ Callers can mutate!
}
// ✅ GOOD - immutable return
public class ReleaseManager
{
private readonly List<Component> _components = new();
public IReadOnlyList<Component> Components => _components.AsReadOnly(); // ✅ Read-only
// Or using ImmutableArray
public ImmutableArray<Component> GetComponents() => _components.ToImmutableArray();
}
Error Handling
No Silent Stubs
Placeholder code MUST throw NotImplementedException or return an explicit error. Never return success from unimplemented paths.
// ❌ BAD - silent stub masks missing implementation
public async Task<Result> DeployToNomadAsync(Deployment deployment, CancellationToken ct)
{
// TODO: implement Nomad deployment
return Result.Success(); // ❌ Ships broken feature!
}
// ✅ GOOD - explicit failure
public async Task<Result> DeployToNomadAsync(Deployment deployment, CancellationToken ct)
{
throw new NotImplementedException(
"Nomad deployment not yet implemented. See SPRINT_20260115_003_AGENTS_nomad_support.md");
}
// ✅ Alternative: return unsupported result
public async Task<Result> DeployToNomadAsync(Deployment deployment, CancellationToken ct)
{
return Result.Failure("Nomad deployment target not yet supported. Use Docker or Compose.");
}
Caching
Bounded Caches with Eviction
Do not use ConcurrentDictionary or Dictionary for caching without eviction policies. Use bounded caches with TTL/LRU eviction.
// ❌ BAD - unbounded growth
public class VersionMapCache
{
private readonly ConcurrentDictionary<string, DigestMapping> _cache = new();
public void Add(string tag, DigestMapping mapping)
{
_cache[tag] = mapping; // ❌ Never evicts, memory grows forever
}
}
// ✅ GOOD - bounded with eviction
public class VersionMapCache
{
private readonly MemoryCache _cache;
public VersionMapCache()
{
_cache = new MemoryCache(new MemoryCacheOptions
{
SizeLimit = 10_000 // Max 10k entries
});
}
public void Add(string tag, DigestMapping mapping)
{
_cache.Set(tag, mapping, new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = TimeSpan.FromHours(1) // ✅ 1 hour TTL
});
}
public DigestMapping? Get(string tag) => _cache.Get<DigestMapping>(tag);
}
Cache TTL Recommendations:
- Integration health checks: 5 minutes
- Version maps (tag → digest): 1 hour
- Environment configs: 30 minutes
- Agent capabilities: 10 minutes
Testing
Test Helpers Must Call Production Code
Test helpers MUST call production code, not reimplement algorithms. Only mock I/O and network boundaries.
// ❌ BAD - test reimplements production logic
public static string ComputeEvidenceHash(DecisionRecord record)
{
// Custom hash implementation in test
var json = JsonSerializer.Serialize(record); // ❌ Different from production!
return SHA256.HashData(Encoding.UTF8.GetBytes(json)).ToHexString();
}
// ✅ GOOD - test uses production code
public static string ComputeEvidenceHash(DecisionRecord record)
{
// Calls production EvidenceHasher
return EvidenceHasher.ComputeHash(record); // ✅ Same as production
}
Path Resolution
Explicit CLI Options for Paths
Do not derive paths from AppContext.BaseDirectory with parent directory walks. Use explicit CLI options or environment variables.
// ❌ BAD - fragile parent walks
var repoRoot = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", ".."));
// ✅ GOOD - explicit option with fallback
[Option("--repo-root", Description = "Repository root path")]
public string? RepoRoot { get; set; }
public string GetRepoRoot() =>
RepoRoot
?? Environment.GetEnvironmentVariable("STELLAOPS_REPO_ROOT")
?? throw new InvalidOperationException(
"Repository root not specified. Use --repo-root or set STELLAOPS_REPO_ROOT.");
Summary Checklist
Before submitting a pull request, verify:
TreatWarningsAsErrorsenabled in project file- All timestamps use
TimeProvider, neverDateTime.UtcNow - All GUIDs use
IGuidGenerator, neverGuid.NewGuid() CancellationTokenpropagated through all async methods- HTTP clients use
IHttpClientFactory, nevernew HttpClient() - Culture-invariant parsing for all formatted strings
- Canonical JSON for evidence/decision records
DateTimeOffsetfor all PostgreSQLtimestamptzcolumns- HLC used for distributed ordering where applicable
- Options classes validated at startup with
ValidateOnStart() - Public APIs return immutable collections
- No silent stubs; unimplemented code throws
NotImplementedException - Caches have bounded size and TTL eviction
- Tests exercise production code, not reimplementations
References
- CLAUDE.md — Stella Ops coding rules
- Test Structure — Test organization guidelines
- Database Schema — Schema patterns
- HLC Documentation — Event ordering with HLC