Skip to content

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_language accordingly)
  • 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

Terminal window
# From your repo root
git clone https://github.com/pace-framework-org/pace-framework-starter pace
cd pace
python -m venv .venv && source .venv/bin/activate
pip install PyYAML jsonschema anthropic

2 — 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-6

Test framework variants

Frameworktest_command
xUnit (default)dotnet test --no-build -v minimal
NUnitdotnet test --no-build -v minimal
MSTestdotnet test --no-build -v minimal
Specific project onlydotnet test tests/MyService.Tests --no-build -v minimal
With coveragedotnet test --no-build -v minimal --collect:"XPlat Code Coverage"

3 — Set credentials

Terminal window
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

Terminal window
cd pace
python pace/orchestrator.py --day 1

What FORGE does with C#

FORGE’s tool-calling loop will:

  1. Read .csproj files to understand project references and NuGet packages
  2. Read existing source files to understand namespaces, patterns, and DI registration
  3. Write new .cs files following C# conventions (PascalCase, async/await, nullable reference types)
  4. Run dotnet build to catch compilation errors
  5. Run dotnet test and read xunit output 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 (FromSqlRaw with user input), XML injection
  • Secrets: hardcoded connection strings, API keys in appsettings.json committed to source
  • Deserialization: insecure BinaryFormatter usage, unsafe Newtonsoft.Json type handling with TypeNameHandling.All
  • Authentication: missing [Authorize] on sensitive controllers, weak JWT validation settings
  • CORS: overly permissive AllowAnyOrigin in 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:

Terminal window
dotnet test --no-build -v minimal \
/p:CollectCoverage=true \
/p:CoverletOutputFormat=lcov \
/p:Threshold=80 \
/p:ThresholdType=line

Update 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:

Terminal window
dotnet tool install --global dotnet-reportgenerator-globaltool

10 — 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 = space
indent_size = 4
dotnet_sort_system_directives_first = true
csharp_style_expression_bodied_methods = when_on_single_line

For 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)

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:

  • BinaryFormatter usage — fix: replace with System.Text.Json or Protobuf
  • TypeNameHandling.All in Newtonsoft.Json — fix: use TypeNameHandling.None or switch to System.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.”