PACE with C# (.NET)
This tutorial shows how to configure PACE for a C# ASP.NET Core project. FORGE will write idiomatic C# code, GATE will run your xUnit or NUnit test suite via dotnet test, and SENTINEL will audit for .NET-specific security risks.
Prerequisites
- .NET 8 SDK (or 6/7 — adjust
primary_languageaccordingly) - An existing ASP.NET Core project with at least a minimal test project
- An API key for your LLM provider
Project layout assumed by this tutorial
MyService/├── pace/ ← PACE subdirectory│ └── pace.config.yaml├── src/│ └── MyService/ ← main project (.csproj)├── tests/│ └── MyService.Tests/ ← test project (.csproj)├── MyService.sln└── .pace/1 — Install PACE
# From your repo rootgit clone https://github.com/pace-framework-org/pace-framework-starter pacecd pacepython -m venv .venv && source .venv/bin/activatepip install PyYAML jsonschema anthropic2 — Configure for C#
Edit pace/pace.config.yaml:
framework_version: "1.0"
product: name: "MyService" description: > An ASP.NET Core REST API for the MyService platform. Handles user management, billing, and notification delivery for B2B customers. github_org: "my-org"
sprint: duration_days: 14
source: dirs: - name: "api" path: "src/MyService/" language: "C#" description: "ASP.NET Core controllers, services, domain models, and EF Core data layer" - name: "tests" path: "tests/MyService.Tests/" language: "C#" description: "xUnit unit and integration tests, including WebApplicationFactory tests"
tech: primary_language: "C# 12 / .NET 8" ci_system: "GitHub Actions" test_command: "dotnet test --no-build -v minimal" build_command: "dotnet build --no-restore -v minimal"
platform: type: local
llm: provider: anthropic model: claude-sonnet-4-6Test framework variants
| Framework | test_command |
|---|---|
| xUnit (default) | dotnet test --no-build -v minimal |
| NUnit | dotnet test --no-build -v minimal |
| MSTest | dotnet test --no-build -v minimal |
| Specific project only | dotnet test tests/MyService.Tests --no-build -v minimal |
| With coverage | dotnet test --no-build -v minimal --collect:"XPlat Code Coverage" |
3 — Set credentials
export ANTHROPIC_API_KEY="sk-ant-..."4 — Write a C# sprint plan
Create plan.yaml at your repo root:
release: v1.0
stories: - id: story-1 title: "INotificationChannel interface and domain entities" status: pending acceptance_criteria: - "INotificationChannel has SendAsync(Notification, CancellationToken) method" - "Notification record has Id, RecipientEmail, Subject, Body, and Channel properties" - "NotificationStatus enum has Pending, Sent, Failed values" - "Domain entities have no external dependencies (no EF Core, no HTTP)" - "dotnet test exits 0 with at least 3 passing unit tests" out_of_scope: - "Email sending implementation" - "Webhook HTTP calls"
- id: story-2 title: "SmtpEmailChannel with polly retry policy" status: pending acceptance_criteria: - "SmtpEmailChannel implements INotificationChannel" - "Retries up to 3 times with exponential backoff on transient errors" - "Unit tests mock ISmtpClient; no real SMTP calls in tests" - "dotnet test exits 0"
- id: story-3 title: "HttpWebhookChannel and NotificationService" status: pending acceptance_criteria: - "HttpWebhookChannel sends POST with JSON payload and HMAC-SHA256 signature" - "NotificationService.SendAsync() routes to correct channel by Notification.Channel" - "Integration test uses WebApplicationFactory; mocks external HTTP" - "dotnet test exits 0"5 — Run Day 1
cd pacepython pace/orchestrator.py --day 1What FORGE does with C#
FORGE’s tool-calling loop will:
- Read
.csprojfiles to understand project references and NuGet packages - Read existing source files to understand namespaces, patterns, and DI registration
- Write new
.csfiles following C# conventions (PascalCase,async/await, nullable reference types) - Run
dotnet buildto catch compilation errors - Run
dotnet testand readxunitoutput to self-correct failures
A typical FORGE run for Day 1:
[FORGE] Reading src/MyService/MyService.csproj ...[FORGE] Reading src/MyService/Program.cs ...[FORGE] Writing src/MyService/Notifications/INotificationChannel.cs ...[FORGE] Writing src/MyService/Notifications/Notification.cs ...[FORGE] Writing src/MyService/Notifications/NotificationStatus.cs ...[FORGE] Writing tests/MyService.Tests/Notifications/NotificationDomainTests.cs ...[FORGE] Running build: dotnet build --no-restore -v minimal ...[FORGE] Running tests: dotnet test --no-build -v minimal ...[FORGE] All tests pass. Calling complete_handoff.What SENTINEL checks for C#
SENTINEL applies .NET-specific checks:
- Injection: SQL injection via raw string queries in EF Core (
FromSqlRawwith user input), XML injection - Secrets: hardcoded connection strings, API keys in
appsettings.jsoncommitted to source - Deserialization: insecure
BinaryFormatterusage, unsafeNewtonsoft.Jsontype handling withTypeNameHandling.All - Authentication: missing
[Authorize]on sensitive controllers, weak JWT validation settings - CORS: overly permissive
AllowAnyOriginin production configurations - Dependency: flags known-vulnerable NuGet packages based on package version patterns
6 — Multi-project solutions
For solutions with several projects, restrict FORGE to the relevant ones:
source: dirs: - name: "notifications" path: "src/MyService.Notifications/" language: "C#" description: "Notification domain, channel implementations, and service orchestration" - name: "notifications-tests" path: "tests/MyService.Notifications.Tests/" language: "C#" description: "xUnit tests for the notifications bounded context"
tech: test_command: "dotnet test tests/MyService.Notifications.Tests --no-build -v minimal" build_command: "dotnet build src/MyService.Notifications --no-restore -v minimal"7 — Azure DevOps or GitHub Actions
For CI integration after local validation:
GitHub Actions — set platform.type: github and see Run PACE on GitHub Actions.
Azure DevOps — Azure DevOps pipelines are not yet a native PACE platform adapter. Use platform.type: local and archive the .pace/ directory as a pipeline artifact. Alternatively, configure platform.type: jenkins if you have a Jenkins instance alongside Azure DevOps.
8 — FORGE hints for C# (.NET)
Add these conventions to product.description so FORGE generates idiomatic code from the first iteration:
product: description: > An ASP.NET Core REST API. Use primary constructor syntax for services (C# 12). Register services via static extension methods, not inline in Program.cs. All DTOs are record types. Domain entities are plain classes with no EF Core attributes on the domain layer. Use IOptions<T> for configuration. All async methods have the Async suffix and accept CancellationToken. Nullable reference types are enabled — annotate all nullable parameters and return types. Use NSubstitute for mocking in tests.9 — Test coverage (coverlet)
coverlet is included by default in xUnit test projects. Add a threshold:
dotnet test --no-build -v minimal \ /p:CollectCoverage=true \ /p:CoverletOutputFormat=lcov \ /p:Threshold=80 \ /p:ThresholdType=lineUpdate test_command:
tech: test_command: "dotnet test --no-build -v minimal /p:CollectCoverage=true /p:Threshold=80 /p:ThresholdType=line"/p:Threshold=80 causes dotnet test to exit non-zero when line coverage drops below 80% — GATE will HOLD.
For coverage HTML reports in CI, add ReportGenerator as a global tool:
dotnet tool install --global dotnet-reportgenerator-globaltool10 — Linter and formatter (dotnet format)
Use dotnet format as build_command to enforce consistent style:
tech: build_command: "dotnet format --verify-no-changes -v minimal" test_command: "dotnet test --no-build -v minimal"Configure rules in .editorconfig:
[*.cs]indent_style = spaceindent_size = 4dotnet_sort_system_directives_first = truecsharp_style_expression_bodied_methods = when_on_single_lineFor stricter static analysis, add Roslyn analyser packages to the test project:
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="8.*"> <PrivateAssets>all</PrivateAssets></PackageReference>FORGE hint: "Code must pass dotnet format --verify-no-changes. Follow .editorconfig rules in the repository."
11 — Database testing
EF Core InMemory (unit tests)
var options = new DbContextOptionsBuilder<AppDbContext>() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options;
using var context = new AppDbContext(options);var repo = new NotificationRepository(context);Tell FORGE: "Repository unit tests use EF Core InMemory provider. Use a unique database name per test to ensure isolation."
WebApplicationFactory with SQLite
For integration tests that need a real relational engine:
public class TestWebApplicationFactory : WebApplicationFactory<Program>{ protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { services.RemoveAll<DbContextOptions<AppDbContext>>(); services.AddDbContext<AppDbContext>(opts => opts.UseSqlite("Data Source=:memory:")); }); }}Testcontainers
[Collection("Database")]public class OrderIntegrationTests : IAsyncLifetime{ private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder().Build();
public Task InitializeAsync() => _postgres.StartAsync(); public Task DisposeAsync() => _postgres.DisposeAsync().AsTask();}12 — Mock patterns (NSubstitute / Moq)
NSubstitute (recommended for C# 12+)
var channel = Substitute.For<INotificationChannel>();channel.SendAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>()) .Returns(Task.CompletedTask);
var service = new NotificationService(channel);await service.SendAsync(notification, CancellationToken.None);
await channel.Received(1).SendAsync(notification, Arg.Any<CancellationToken>());Moq
var mock = new Mock<INotificationChannel>();mock.Setup(c => c.SendAsync(It.IsAny<Notification>(), It.IsAny<CancellationToken>())) .Returns(Task.CompletedTask);
var service = new NotificationService(mock.Object);await service.SendAsync(notification, CancellationToken.None);
mock.Verify(c => c.SendAsync(notification, It.IsAny<CancellationToken>()), Times.Once);FORGE hint: "Use NSubstitute for mocking. Prefer interface injection — never mock concrete classes."
13 — Multi-day sprint (Day 2 builds on Day 1)
After Day 1 ships, SCRIBE records the new types in .pace/context/engineering.md. Day 2’s FORGE reads it:
[FORGE] Reading .pace/context/engineering.md ...[FORGE] Found: INotificationChannel (interface, SendAsync method)[FORGE] Found: Notification (record, Id/RecipientEmail/Subject/Body/Channel)[FORGE] Writing src/MyService/Notifications/SmtpEmailChannel.cs ...[FORGE] Writing tests/MyService.Tests/Notifications/SmtpEmailChannelTests.cs ...Story-2 criteria can reference INotificationChannel and Notification by name — FORGE already knows they exist.
14 — Advisory clearance story
- id: story-5 title: "Security and DevOps advisory clearance" status: pending acceptance_criteria: - "All open SENTINEL advisories resolved or escalated with written justification" - "All open CONDUIT advisories resolved or escalated" - "dotnet test exits 0 after any remediation changes"Common C# SENTINEL advisories:
BinaryFormatterusage — fix: replace withSystem.Text.Jsonor ProtobufTypeNameHandling.Allin Newtonsoft.Json — fix: useTypeNameHandling.Noneor switch toSystem.Text.Json- Hardcoded connection strings in
appsettings.json— fix: use environment variable substitution or Azure Key Vault
15 — CI build caching
Cache NuGet packages to avoid re-downloading on every PACE run:
- uses: actions/cache@v4 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/packages.lock.json') }} restore-keys: ${{ runner.os }}-nuget-16 — Windows notes
dotnet CLI works natively on Windows — no WSL required. The --no-build flag and all test commands behave identically on Windows, macOS, and Linux. If the virtualenv PATH doesn’t include dotnet, use the full path:
tech: test_command: "C:\\Program Files\\dotnet\\dotnet.exe test --no-build -v minimal"Common issues
dotnet: command not found
PACE runs commands from the repo root. Ensure dotnet is on PATH in the shell where you run the orchestrator, or use the full path: test_command: "/usr/local/share/dotnet/dotnet test ...".
Build succeeds but tests not found
dotnet test with --no-build requires the binary to already exist. If you skip build_command, ensure you have run dotnet build manually at least once before the first --day 1 run.
FORGE adds packages not in .csproj
FORGE may using a NuGet package it assumes is available. If dotnet build fails with missing namespace errors, FORGE will retry. If it loops, add the package manually (dotnet add package ...) and re-run the day.
Nullable reference type warnings treated as errors
If your project has <TreatWarningsAsErrors>true</TreatWarningsAsErrors>, FORGE may generate code with nullable warnings that block compilation. Include a note in product.description such as “The codebase enforces nullable reference types — always annotate nullable parameters and return types.”