Comprehensive Claude Code guidance system with: - 5 agents: tdd-guardian, code-reviewer, security-scanner, refactor-scan, dependency-audit - 18 skills covering languages (Python, TypeScript, Rust, Go, Java, C#), infrastructure (AWS, Azure, GCP, Terraform, Ansible, Docker/K8s, Database, CI/CD), testing (TDD, UI, Browser), and patterns (Monorepo, API Design, Observability) - 3 hooks: secret detection, auto-formatting, TDD git pre-commit - Strict TDD enforcement with 80%+ coverage requirements - Multi-model strategy: Opus for planning, Sonnet for execution (opusplan) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
667 lines
18 KiB
Markdown
667 lines
18 KiB
Markdown
---
|
|
name: csharp-development
|
|
description: C# and .NET development patterns with xUnit, Clean Architecture, and modern C# practices. Use when writing C# code.
|
|
---
|
|
|
|
# C# Development Skill
|
|
|
|
## Project Structure (Clean Architecture)
|
|
|
|
```
|
|
Solution/
|
|
├── src/
|
|
│ ├── Domain/ # Enterprise business rules
|
|
│ │ ├── Entities/
|
|
│ │ ├── ValueObjects/
|
|
│ │ ├── Exceptions/
|
|
│ │ └── Domain.csproj
|
|
│ ├── Application/ # Application business rules
|
|
│ │ ├── Common/
|
|
│ │ │ ├── Interfaces/
|
|
│ │ │ └── Behaviours/
|
|
│ │ ├── Features/
|
|
│ │ │ └── Users/
|
|
│ │ │ ├── Commands/
|
|
│ │ │ └── Queries/
|
|
│ │ └── Application.csproj
|
|
│ ├── Infrastructure/ # External concerns
|
|
│ │ ├── Persistence/
|
|
│ │ ├── Identity/
|
|
│ │ └── Infrastructure.csproj
|
|
│ └── Api/ # Presentation layer
|
|
│ ├── Controllers/
|
|
│ ├── Middleware/
|
|
│ └── Api.csproj
|
|
├── tests/
|
|
│ ├── Domain.Tests/
|
|
│ ├── Application.Tests/
|
|
│ ├── Infrastructure.Tests/
|
|
│ └── Api.Tests/
|
|
└── Solution.sln
|
|
```
|
|
|
|
## Testing with xUnit
|
|
|
|
### Unit Tests with Fluent Assertions
|
|
```csharp
|
|
using FluentAssertions;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace Application.Tests.Features.Users;
|
|
|
|
public class CreateUserCommandHandlerTests
|
|
{
|
|
private readonly Mock<IUserRepository> _userRepositoryMock;
|
|
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
|
|
private readonly CreateUserCommandHandler _handler;
|
|
|
|
public CreateUserCommandHandlerTests()
|
|
{
|
|
_userRepositoryMock = new Mock<IUserRepository>();
|
|
_unitOfWorkMock = new Mock<IUnitOfWork>();
|
|
_handler = new CreateUserCommandHandler(
|
|
_userRepositoryMock.Object,
|
|
_unitOfWorkMock.Object);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Handle_WithValidCommand_ShouldCreateUser()
|
|
{
|
|
// Arrange
|
|
var command = UserFixtures.CreateUserCommand();
|
|
_userRepositoryMock
|
|
.Setup(x => x.ExistsByEmailAsync(command.Email, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(false);
|
|
|
|
// Act
|
|
var result = await _handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result.Id.Should().NotBeEmpty();
|
|
result.Email.Should().Be(command.Email);
|
|
|
|
_userRepositoryMock.Verify(
|
|
x => x.AddAsync(It.IsAny<User>(), It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
_unitOfWorkMock.Verify(
|
|
x => x.SaveChangesAsync(It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Handle_WithDuplicateEmail_ShouldThrowDuplicateEmailException()
|
|
{
|
|
// Arrange
|
|
var command = UserFixtures.CreateUserCommand();
|
|
_userRepositoryMock
|
|
.Setup(x => x.ExistsByEmailAsync(command.Email, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(true);
|
|
|
|
// Act
|
|
var act = () => _handler.Handle(command, CancellationToken.None);
|
|
|
|
// Assert
|
|
await act.Should()
|
|
.ThrowAsync<DuplicateEmailException>()
|
|
.WithMessage($"*{command.Email}*");
|
|
}
|
|
}
|
|
```
|
|
|
|
### Test Fixtures
|
|
```csharp
|
|
namespace Application.Tests.Fixtures;
|
|
|
|
public static class UserFixtures
|
|
{
|
|
public static User User(Action<User>? customize = null)
|
|
{
|
|
var user = new User
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Email = "test@example.com",
|
|
Name = "Test User",
|
|
Role = Role.User,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
customize?.Invoke(user);
|
|
return user;
|
|
}
|
|
|
|
public static CreateUserCommand CreateUserCommand(
|
|
Action<CreateUserCommand>? customize = null)
|
|
{
|
|
var command = new CreateUserCommand
|
|
{
|
|
Email = "newuser@example.com",
|
|
Name = "New User",
|
|
Password = "SecurePass123!"
|
|
};
|
|
|
|
customize?.Invoke(command);
|
|
return command;
|
|
}
|
|
|
|
public static UserDto UserDto(Action<UserDto>? customize = null)
|
|
{
|
|
var dto = new UserDto
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Email = "test@example.com",
|
|
Name = "Test User",
|
|
Role = Role.User.ToString()
|
|
};
|
|
|
|
customize?.Invoke(dto);
|
|
return dto;
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
var adminUser = UserFixtures.User(u => u.Role = Role.Admin);
|
|
var customCommand = UserFixtures.CreateUserCommand(c => c.Email = "custom@example.com");
|
|
```
|
|
|
|
### Theory Tests (Parameterized)
|
|
```csharp
|
|
namespace Domain.Tests.ValueObjects;
|
|
|
|
public class EmailTests
|
|
{
|
|
[Theory]
|
|
[InlineData("user@example.com", true)]
|
|
[InlineData("user.name@example.co.uk", true)]
|
|
[InlineData("invalid", false)]
|
|
[InlineData("missing@domain", false)]
|
|
[InlineData("", false)]
|
|
[InlineData(null, false)]
|
|
public void IsValid_ShouldReturnExpectedResult(string? email, bool expected)
|
|
{
|
|
// Act
|
|
var result = Email.IsValid(email);
|
|
|
|
// Assert
|
|
result.Should().Be(expected);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(ValidEmailTestData))]
|
|
public void Create_WithValidEmail_ShouldSucceed(string email)
|
|
{
|
|
// Act
|
|
var result = Email.Create(email);
|
|
|
|
// Assert
|
|
result.IsSuccess.Should().BeTrue();
|
|
result.Value.Value.Should().Be(email.ToLowerInvariant());
|
|
}
|
|
|
|
public static IEnumerable<object[]> ValidEmailTestData()
|
|
{
|
|
yield return new object[] { "user@example.com" };
|
|
yield return new object[] { "USER@EXAMPLE.COM" };
|
|
yield return new object[] { "user.name+tag@example.co.uk" };
|
|
}
|
|
}
|
|
```
|
|
|
|
### Integration Tests with WebApplicationFactory
|
|
```csharp
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
|
|
namespace Api.Tests.Controllers;
|
|
|
|
public class UsersControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
|
{
|
|
private readonly HttpClient _client;
|
|
private readonly WebApplicationFactory<Program> _factory;
|
|
|
|
public UsersControllerTests(WebApplicationFactory<Program> factory)
|
|
{
|
|
_factory = factory.WithWebHostBuilder(builder =>
|
|
{
|
|
builder.ConfigureServices(services =>
|
|
{
|
|
// Replace services with test doubles
|
|
services.RemoveAll<IUserRepository>();
|
|
services.AddScoped<IUserRepository, InMemoryUserRepository>();
|
|
});
|
|
});
|
|
_client = _factory.CreateClient();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateUser_WithValidRequest_ReturnsCreated()
|
|
{
|
|
// Arrange
|
|
var request = new CreateUserRequest
|
|
{
|
|
Email = "new@example.com",
|
|
Name = "New User",
|
|
Password = "SecurePass123!"
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PostAsJsonAsync("/api/users", request);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
|
|
var user = await response.Content.ReadFromJsonAsync<UserDto>();
|
|
user.Should().NotBeNull();
|
|
user!.Email.Should().Be(request.Email);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetUser_WhenNotFound_ReturnsNotFound()
|
|
{
|
|
// Act
|
|
var response = await _client.GetAsync($"/api/users/{Guid.NewGuid()}");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Domain Models
|
|
|
|
### Entity with Value Objects
|
|
```csharp
|
|
namespace Domain.Entities;
|
|
|
|
public class User : BaseEntity
|
|
{
|
|
private User() { } // EF Core constructor
|
|
|
|
public Guid Id { get; private set; }
|
|
public Email Email { get; private set; } = null!;
|
|
public string Name { get; private set; } = null!;
|
|
public Role Role { get; private set; }
|
|
public DateTime CreatedAt { get; private set; }
|
|
public DateTime UpdatedAt { get; private set; }
|
|
|
|
public static Result<User> Create(string email, string name)
|
|
{
|
|
var emailResult = Email.Create(email);
|
|
if (emailResult.IsFailure)
|
|
{
|
|
return Result.Failure<User>(emailResult.Error);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
return Result.Failure<User>(DomainErrors.User.NameRequired);
|
|
}
|
|
|
|
var user = new User
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Email = emailResult.Value,
|
|
Name = name.Trim(),
|
|
Role = Role.User,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
user.RaiseDomainEvent(new UserCreatedEvent(user.Id, user.Email.Value));
|
|
|
|
return Result.Success(user);
|
|
}
|
|
|
|
public Result UpdateName(string name)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
return Result.Failure(DomainErrors.User.NameRequired);
|
|
}
|
|
|
|
Name = name.Trim();
|
|
UpdatedAt = DateTime.UtcNow;
|
|
|
|
return Result.Success();
|
|
}
|
|
|
|
public void PromoteToAdmin()
|
|
{
|
|
Role = Role.Admin;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
|
|
RaiseDomainEvent(new UserPromotedEvent(Id));
|
|
}
|
|
}
|
|
```
|
|
|
|
### Value Object
|
|
```csharp
|
|
namespace Domain.ValueObjects;
|
|
|
|
public sealed class Email : ValueObject
|
|
{
|
|
private static readonly Regex EmailRegex = new(
|
|
@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
|
|
RegexOptions.Compiled);
|
|
|
|
public string Value { get; }
|
|
|
|
private Email(string value) => Value = value;
|
|
|
|
public static Result<Email> Create(string? email)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(email))
|
|
{
|
|
return Result.Failure<Email>(DomainErrors.Email.Empty);
|
|
}
|
|
|
|
var normalizedEmail = email.Trim().ToLowerInvariant();
|
|
|
|
if (!EmailRegex.IsMatch(normalizedEmail))
|
|
{
|
|
return Result.Failure<Email>(DomainErrors.Email.InvalidFormat);
|
|
}
|
|
|
|
return Result.Success(new Email(normalizedEmail));
|
|
}
|
|
|
|
public static bool IsValid(string? email) =>
|
|
!string.IsNullOrWhiteSpace(email) && EmailRegex.IsMatch(email);
|
|
|
|
protected override IEnumerable<object> GetEqualityComponents()
|
|
{
|
|
yield return Value;
|
|
}
|
|
|
|
public override string ToString() => Value;
|
|
|
|
public static implicit operator string(Email email) => email.Value;
|
|
}
|
|
```
|
|
|
|
### Result Pattern
|
|
```csharp
|
|
namespace Domain.Common;
|
|
|
|
public class Result
|
|
{
|
|
protected Result(bool isSuccess, Error error)
|
|
{
|
|
if (isSuccess && error != Error.None ||
|
|
!isSuccess && error == Error.None)
|
|
{
|
|
throw new ArgumentException("Invalid error", nameof(error));
|
|
}
|
|
|
|
IsSuccess = isSuccess;
|
|
Error = error;
|
|
}
|
|
|
|
public bool IsSuccess { get; }
|
|
public bool IsFailure => !IsSuccess;
|
|
public Error Error { get; }
|
|
|
|
public static Result Success() => new(true, Error.None);
|
|
public static Result Failure(Error error) => new(false, error);
|
|
public static Result<T> Success<T>(T value) => new(value, true, Error.None);
|
|
public static Result<T> Failure<T>(Error error) => new(default!, false, error);
|
|
}
|
|
|
|
public class Result<T> : Result
|
|
{
|
|
private readonly T _value;
|
|
|
|
protected internal Result(T value, bool isSuccess, Error error)
|
|
: base(isSuccess, error)
|
|
{
|
|
_value = value;
|
|
}
|
|
|
|
public T Value => IsSuccess
|
|
? _value
|
|
: throw new InvalidOperationException("Cannot access value of failed result");
|
|
}
|
|
|
|
public record Error(string Code, string Message)
|
|
{
|
|
public static readonly Error None = new(string.Empty, string.Empty);
|
|
}
|
|
```
|
|
|
|
## CQRS with MediatR
|
|
|
|
### Command
|
|
```csharp
|
|
namespace Application.Features.Users.Commands;
|
|
|
|
public record CreateUserCommand : IRequest<Result<UserDto>>
|
|
{
|
|
public required string Email { get; init; }
|
|
public required string Name { get; init; }
|
|
public required string Password { get; init; }
|
|
}
|
|
|
|
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
|
|
{
|
|
public CreateUserCommandValidator()
|
|
{
|
|
RuleFor(x => x.Email)
|
|
.NotEmpty()
|
|
.EmailAddress()
|
|
.MaximumLength(255);
|
|
|
|
RuleFor(x => x.Name)
|
|
.NotEmpty()
|
|
.MaximumLength(100);
|
|
|
|
RuleFor(x => x.Password)
|
|
.NotEmpty()
|
|
.MinimumLength(8)
|
|
.Matches("[A-Z]").WithMessage("Password must contain uppercase letter")
|
|
.Matches("[a-z]").WithMessage("Password must contain lowercase letter")
|
|
.Matches("[0-9]").WithMessage("Password must contain digit")
|
|
.Matches("[^a-zA-Z0-9]").WithMessage("Password must contain special character");
|
|
}
|
|
}
|
|
|
|
public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, Result<UserDto>>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly IPasswordHasher _passwordHasher;
|
|
|
|
public CreateUserCommandHandler(
|
|
IUserRepository userRepository,
|
|
IUnitOfWork unitOfWork,
|
|
IPasswordHasher passwordHasher)
|
|
{
|
|
_userRepository = userRepository;
|
|
_unitOfWork = unitOfWork;
|
|
_passwordHasher = passwordHasher;
|
|
}
|
|
|
|
public async Task<Result<UserDto>> Handle(
|
|
CreateUserCommand request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (await _userRepository.ExistsByEmailAsync(request.Email, cancellationToken))
|
|
{
|
|
return Result.Failure<UserDto>(DomainErrors.User.DuplicateEmail);
|
|
}
|
|
|
|
var userResult = User.Create(request.Email, request.Name);
|
|
if (userResult.IsFailure)
|
|
{
|
|
return Result.Failure<UserDto>(userResult.Error);
|
|
}
|
|
|
|
var user = userResult.Value;
|
|
|
|
await _userRepository.AddAsync(user, cancellationToken);
|
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
|
|
|
return Result.Success(user.ToDto());
|
|
}
|
|
}
|
|
```
|
|
|
|
### Query
|
|
```csharp
|
|
namespace Application.Features.Users.Queries;
|
|
|
|
public record GetUserByIdQuery(Guid Id) : IRequest<Result<UserDto>>;
|
|
|
|
public class GetUserByIdQueryHandler : IRequestHandler<GetUserByIdQuery, Result<UserDto>>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
|
|
public GetUserByIdQueryHandler(IUserRepository userRepository)
|
|
{
|
|
_userRepository = userRepository;
|
|
}
|
|
|
|
public async Task<Result<UserDto>> Handle(
|
|
GetUserByIdQuery request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var user = await _userRepository.GetByIdAsync(request.Id, cancellationToken);
|
|
|
|
if (user is null)
|
|
{
|
|
return Result.Failure<UserDto>(DomainErrors.User.NotFound);
|
|
}
|
|
|
|
return Result.Success(user.ToDto());
|
|
}
|
|
}
|
|
```
|
|
|
|
## API Controllers
|
|
|
|
```csharp
|
|
namespace Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/[controller]")]
|
|
public class UsersController : ControllerBase
|
|
{
|
|
private readonly ISender _sender;
|
|
|
|
public UsersController(ISender sender)
|
|
{
|
|
_sender = sender;
|
|
}
|
|
|
|
[HttpGet("{id:guid}")]
|
|
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> GetUser(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var result = await _sender.Send(new GetUserByIdQuery(id), cancellationToken);
|
|
|
|
return result.IsSuccess
|
|
? Ok(result.Value)
|
|
: NotFound(CreateProblemDetails(result.Error));
|
|
}
|
|
|
|
[HttpPost]
|
|
[ProducesResponseType(typeof(UserDto), StatusCodes.Status201Created)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
|
public async Task<IActionResult> CreateUser(
|
|
[FromBody] CreateUserRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var command = new CreateUserCommand
|
|
{
|
|
Email = request.Email,
|
|
Name = request.Name,
|
|
Password = request.Password
|
|
};
|
|
|
|
var result = await _sender.Send(command, cancellationToken);
|
|
|
|
if (result.IsFailure)
|
|
{
|
|
return result.Error.Code == "User.DuplicateEmail"
|
|
? Conflict(CreateProblemDetails(result.Error))
|
|
: BadRequest(CreateProblemDetails(result.Error));
|
|
}
|
|
|
|
return CreatedAtAction(
|
|
nameof(GetUser),
|
|
new { id = result.Value.Id },
|
|
result.Value);
|
|
}
|
|
|
|
private static ProblemDetails CreateProblemDetails(Error error) =>
|
|
new()
|
|
{
|
|
Title = error.Code,
|
|
Detail = error.Message,
|
|
Status = StatusCodes.Status400BadRequest
|
|
};
|
|
}
|
|
```
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
# Build and test
|
|
dotnet build
|
|
dotnet test
|
|
dotnet test --collect:"XPlat Code Coverage"
|
|
|
|
# Run specific test project
|
|
dotnet test tests/Application.Tests
|
|
|
|
# Run with filter
|
|
dotnet test --filter "FullyQualifiedName~UserService"
|
|
|
|
# Generate coverage report (requires reportgenerator)
|
|
dotnet tool install -g dotnet-reportgenerator-globaltool
|
|
reportgenerator -reports:**/coverage.cobertura.xml -targetdir:coveragereport
|
|
|
|
# Run application
|
|
dotnet run --project src/Api
|
|
|
|
# EF Core migrations
|
|
dotnet ef migrations add InitialCreate --project src/Infrastructure --startup-project src/Api
|
|
dotnet ef database update --project src/Infrastructure --startup-project src/Api
|
|
```
|
|
|
|
## NuGet Dependencies
|
|
|
|
```xml
|
|
<!-- Domain.csproj - Minimal dependencies -->
|
|
<ItemGroup>
|
|
<PackageReference Include="FluentResults" Version="3.15.2" />
|
|
</ItemGroup>
|
|
|
|
<!-- Application.csproj -->
|
|
<ItemGroup>
|
|
<PackageReference Include="MediatR" Version="12.2.0" />
|
|
<PackageReference Include="FluentValidation" Version="11.9.0" />
|
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.0" />
|
|
</ItemGroup>
|
|
|
|
<!-- Infrastructure.csproj -->
|
|
<ItemGroup>
|
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
|
|
</ItemGroup>
|
|
|
|
<!-- Test projects -->
|
|
<ItemGroup>
|
|
<PackageReference Include="xunit" Version="2.6.6" />
|
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6" />
|
|
<PackageReference Include="Moq" Version="4.20.70" />
|
|
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
|
|
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
|
</ItemGroup>
|
|
```
|