jgarrison929

dotnet-expert

0
0
# Install this skill:
npx skills add jgarrison929/openclaw-skills --skill "dotnet-expert"

Install specific skill from multi-skill repository

# Description

Use when building .NET 8/9 applications, ASP.NET Core APIs, Entity Framework Core, MediatR CQRS, modular monolith architecture, FluentValidation, Result pattern, JWT authentication, or any C# backend development question.

# SKILL.md


name: dotnet-expert
version: 1.0.0
description: Use when building .NET 8/9 applications, ASP.NET Core APIs, Entity Framework Core, MediatR CQRS, modular monolith architecture, FluentValidation, Result pattern, JWT authentication, or any C# backend development question.
triggers:
- .NET
- dotnet
- C#
- ASP.NET
- Entity Framework
- EF Core
- MediatR
- CQRS
- FluentValidation
- Minimal API
- controller
- DbContext
- migration
- Pitbull
- modular monolith
- Result pattern
role: specialist
scope: implementation
output-format: code


.NET Expert

Senior .NET 9 / ASP.NET Core specialist with expertise in clean architecture, CQRS, and modular monolith patterns.

Role Definition

You are a senior .NET engineer building production-grade APIs with ASP.NET Core, Entity Framework Core 9, MediatR, and FluentValidation. You follow clean architecture principles with a pragmatic approach.

Core Principles

  1. Result pattern over exceptions for business logic β€” exceptions for infrastructure only
  2. CQRS with MediatR β€” separate commands (writes) from queries (reads)
  3. FluentValidation for all input validation in the pipeline
  4. Modular monolith β€” organized by feature/domain, not by technical layer
  5. Strongly-typed IDs to prevent primitive obsession
  6. Async all the way β€” never .Result or .Wait()

Project Structure (Modular Monolith)

src/
β”œβ”€β”€ Api/                          # ASP.NET Core host
β”‚   β”œβ”€β”€ Program.cs
β”‚   β”œβ”€β”€ appsettings.json
β”‚   └── Endpoints/                # Minimal API endpoint definitions
β”œβ”€β”€ Modules/
β”‚   β”œβ”€β”€ Users/
β”‚   β”‚   β”œβ”€β”€ Users.Core/           # Domain entities, interfaces
β”‚   β”‚   β”œβ”€β”€ Users.Application/    # Commands, queries, handlers
β”‚   β”‚   └── Users.Infrastructure/ # EF Core, external services
β”‚   β”œβ”€β”€ Orders/
β”‚   β”‚   β”œβ”€β”€ Orders.Core/
β”‚   β”‚   β”œβ”€β”€ Orders.Application/
β”‚   β”‚   └── Orders.Infrastructure/
β”‚   └── Shared/
β”‚       β”œβ”€β”€ Shared.Core/          # Common abstractions
β”‚       └── Shared.Infrastructure/# Cross-cutting concerns
└── Tests/
    β”œβ”€β”€ Users.Tests/
    └── Orders.Tests/

Minimal API Patterns

Basic Endpoint Group

// Api/Endpoints/UserEndpoints.cs
public static class UserEndpoints
{
    public static void MapUserEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/users")
            .WithTags("Users")
            .RequireAuthorization();

        group.MapGet("/", GetUsers);
        group.MapGet("/{id:guid}", GetUserById);
        group.MapPost("/", CreateUser);
        group.MapPut("/{id:guid}", UpdateUser);
        group.MapDelete("/{id:guid}", DeleteUser);
    }

    private static async Task<IResult> GetUsers(
        [AsParameters] GetUsersQuery query,
        ISender mediator,
        CancellationToken ct)
    {
        var result = await mediator.Send(query, ct);
        return result.Match(
            success => Results.Ok(success),
            error => Results.Problem(error.ToProblemDetails()));
    }

    private static async Task<IResult> GetUserById(
        Guid id,
        ISender mediator,
        CancellationToken ct)
    {
        var result = await mediator.Send(new GetUserByIdQuery(id), ct);
        return result.Match(
            success => Results.Ok(success),
            error => error.Type == ErrorType.NotFound
                ? Results.NotFound()
                : Results.Problem(error.ToProblemDetails()));
    }

    private static async Task<IResult> CreateUser(
        CreateUserCommand command,
        ISender mediator,
        CancellationToken ct)
    {
        var result = await mediator.Send(command, ct);
        return result.Match(
            success => Results.Created($"/api/users/{success.Id}", success),
            error => Results.Problem(error.ToProblemDetails()));
    }
}

Program.cs Setup

var builder = WebApplication.CreateBuilder(args);

// Add modules
builder.Services.AddUsersModule(builder.Configuration);
builder.Services.AddOrdersModule(builder.Configuration);

// Add shared infrastructure
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssemblies(
        typeof(UsersModule).Assembly,
        typeof(OrdersModule).Assembly));

builder.Services.AddValidatorsFromAssemblies(new[]
{
    typeof(UsersModule).Assembly,
    typeof(OrdersModule).Assembly,
});

// Add validation pipeline behavior
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)),
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapUserEndpoints();
app.MapOrderEndpoints();

app.Run();

Result Pattern

Result Type

// Shared.Core/Result.cs
public sealed class Result<T>
{
    public T? Value { get; }
    public Error? Error { get; }
    public bool IsSuccess { get; }

    private Result(T value) { Value = value; IsSuccess = true; }
    private Result(Error error) { Error = error; IsSuccess = false; }

    public static Result<T> Success(T value) => new(value);
    public static Result<T> Failure(Error error) => new(error);

    public TResult Match<TResult>(
        Func<T, TResult> onSuccess,
        Func<Error, TResult> onFailure) =>
        IsSuccess ? onSuccess(Value!) : onFailure(Error!);
}

public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Failure)
{
    public static Error NotFound(string code, string message) => new(code, message, ErrorType.NotFound);
    public static Error Validation(string code, string message) => new(code, message, ErrorType.Validation);
    public static Error Conflict(string code, string message) => new(code, message, ErrorType.Conflict);
    public static Error Forbidden(string code, string message) => new(code, message, ErrorType.Forbidden);

    public ProblemDetails ToProblemDetails() => new()
    {
        Title = Code,
        Detail = Message,
        Status = Type switch
        {
            ErrorType.NotFound => StatusCodes.Status404NotFound,
            ErrorType.Validation => StatusCodes.Status400BadRequest,
            ErrorType.Conflict => StatusCodes.Status409Conflict,
            ErrorType.Forbidden => StatusCodes.Status403Forbidden,
            _ => StatusCodes.Status500InternalServerError,
        },
    };
}

public enum ErrorType { Failure, NotFound, Validation, Conflict, Forbidden }

Usage in Handlers

// No exceptions for business logic!
public sealed class CreateUserHandler : IRequestHandler<CreateUserCommand, Result<UserResponse>>
{
    private readonly AppDbContext _db;

    public CreateUserHandler(AppDbContext db) => _db = db;

    public async Task<Result<UserResponse>> Handle(
        CreateUserCommand command, CancellationToken ct)
    {
        // Business rule validation returns errors, not exceptions
        var existingUser = await _db.Users
            .AnyAsync(u => u.Email == command.Email, ct);

        if (existingUser)
            return Result<UserResponse>.Failure(
                Error.Conflict("User.DuplicateEmail", "A user with this email already exists"));

        var user = new User
        {
            Id = Guid.NewGuid(),
            Email = command.Email,
            Name = command.Name,
            CreatedAt = DateTime.UtcNow,
        };

        _db.Users.Add(user);
        await _db.SaveChangesAsync(ct);

        return Result<UserResponse>.Success(user.ToResponse());
    }
}

MediatR CQRS

Commands (Write Operations)

// Users.Application/Commands/CreateUserCommand.cs
public sealed record CreateUserCommand(
    string Email,
    string Name,
    string Password) : IRequest<Result<UserResponse>>;

Queries (Read Operations)

// Users.Application/Queries/GetUsersQuery.cs
public sealed record GetUsersQuery(
    int Page = 1,
    int PageSize = 20,
    string? Search = null) : IRequest<Result<PagedResult<UserResponse>>>;

public sealed class GetUsersHandler : IRequestHandler<GetUsersQuery, Result<PagedResult<UserResponse>>>
{
    private readonly AppDbContext _db;

    public GetUsersHandler(AppDbContext db) => _db = db;

    public async Task<Result<PagedResult<UserResponse>>> Handle(
        GetUsersQuery query, CancellationToken ct)
    {
        var dbQuery = _db.Users.AsNoTracking();

        if (!string.IsNullOrWhiteSpace(query.Search))
            dbQuery = dbQuery.Where(u =>
                u.Name.Contains(query.Search) || u.Email.Contains(query.Search));

        var total = await dbQuery.CountAsync(ct);

        var users = await dbQuery
            .OrderBy(u => u.Name)
            .Skip((query.Page - 1) * query.PageSize)
            .Take(query.PageSize)
            .Select(u => u.ToResponse())
            .ToListAsync(ct);

        return Result<PagedResult<UserResponse>>.Success(
            new PagedResult<UserResponse>(users, total, query.Page, query.PageSize));
    }
}

Validation Pipeline Behavior

public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
        => _validators = validators;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (!_validators.Any()) return await next();

        var context = new ValidationContext<TRequest>(request);
        var results = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, ct)));

        var failures = results
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Count > 0)
            throw new ValidationException(failures);

        return await next();
    }
}

FluentValidation

public sealed class CreateUserValidator : AbstractValidator<CreateUserCommand>
{
    public CreateUserValidator()
    {
        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("Email is required")
            .EmailAddress().WithMessage("Invalid email format")
            .MaximumLength(255);

        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Name is required")
            .MinimumLength(2)
            .MaximumLength(100)
            .Matches(@"^[a-zA-Z\s'-]+$").WithMessage("Name contains invalid characters");

        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 a number")
            .Matches("[^a-zA-Z0-9]").WithMessage("Password must contain a special character");
    }
}

Entity Framework Core 9

DbContext

public sealed class AppDbContext : DbContext
{
    public DbSet<User> Users => Set<User>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }

    public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        // Auto-set audit fields
        foreach (var entry in ChangeTracker.Entries<IAuditable>())
        {
            if (entry.State == EntityState.Added)
                entry.Entity.CreatedAt = DateTime.UtcNow;

            if (entry.State == EntityState.Modified)
                entry.Entity.UpdatedAt = DateTime.UtcNow;
        }

        return await base.SaveChangesAsync(ct);
    }
}

Entity Configuration

public sealed class UserConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        builder.ToTable("users");

        builder.HasKey(u => u.Id);

        builder.Property(u => u.Email)
            .HasMaxLength(255)
            .IsRequired();

        builder.HasIndex(u => u.Email).IsUnique();

        builder.Property(u => u.Name)
            .HasMaxLength(100)
            .IsRequired();

        builder.Property(u => u.PasswordHash)
            .HasMaxLength(255)
            .IsRequired();

        builder.HasMany(u => u.Orders)
            .WithOne(o => o.User)
            .HasForeignKey(o => o.UserId)
            .OnDelete(DeleteBehavior.Cascade);

        // Query filter for soft delete
        builder.HasQueryFilter(u => u.DeletedAt == null);
    }
}

Migrations

# Create migration
dotnet ef migrations add AddUserTable -p src/Users.Infrastructure -s src/Api

# Apply migration
dotnet ef database update -p src/Users.Infrastructure -s src/Api

# Generate SQL script (for production)
dotnet ef migrations script -p src/Users.Infrastructure -s src/Api -o migrations.sql --idempotent

Query Optimization

// ❌ BAD: N+1 queries
var users = await _db.Users.ToListAsync(ct);
foreach (var user in users)
{
    var orders = await _db.Orders.Where(o => o.UserId == user.Id).ToListAsync(ct);
}

// βœ… GOOD: Eager loading
var users = await _db.Users
    .Include(u => u.Orders)
    .ToListAsync(ct);

// βœ… BEST: Projection (only load what you need)
var users = await _db.Users
    .AsNoTracking()
    .Select(u => new UserResponse
    {
        Id = u.Id,
        Name = u.Name,
        Email = u.Email,
        OrderCount = u.Orders.Count,
    })
    .ToListAsync(ct);

ASP.NET Identity + JWT Auth

Identity Setup

builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options =>
{
    options.Password.RequireDigit = true;
    options.Password.RequiredLength = 8;
    options.Password.RequireUppercase = true;
    options.Password.RequireNonAlphanumeric = true;
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
    options.Lockout.MaxFailedAccessAttempts = 5;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();

JWT Token Generation

public sealed class TokenService : ITokenService
{
    private readonly IConfiguration _config;

    public TokenService(IConfiguration config) => _config = config;

    public string GenerateAccessToken(ApplicationUser user, IList<string> roles)
    {
        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new(ClaimTypes.Email, user.Email!),
            new(ClaimTypes.Name, user.UserName!),
        };

        claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));

        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));

        var token = new JwtSecurityToken(
            issuer: _config["Jwt:Issuer"],
            audience: _config["Jwt:Audience"],
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(15),
            signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    public string GenerateRefreshToken()
    {
        var randomBytes = new byte[64];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomBytes);
        return Convert.ToBase64String(randomBytes);
    }
}

Domain Entity Pattern

public sealed class Order : IAuditable
{
    public Guid Id { get; private set; }
    public Guid UserId { get; private set; }
    public OrderStatus Status { get; private set; }
    public decimal Total { get; private set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }

    private readonly List<OrderItem> _items = [];
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    private Order() { } // EF Core

    public static Order Create(Guid userId)
    {
        return new Order
        {
            Id = Guid.NewGuid(),
            UserId = userId,
            Status = OrderStatus.Pending,
            Total = 0,
        };
    }

    public Result<OrderItem> AddItem(Guid productId, int quantity, decimal unitPrice)
    {
        if (Status != OrderStatus.Pending)
            return Result<OrderItem>.Failure(
                Error.Validation("Order.NotPending", "Cannot add items to a non-pending order"));

        if (quantity <= 0)
            return Result<OrderItem>.Failure(
                Error.Validation("Order.InvalidQuantity", "Quantity must be positive"));

        var item = new OrderItem(Guid.NewGuid(), Id, productId, quantity, unitPrice);
        _items.Add(item);
        RecalculateTotal();

        return Result<OrderItem>.Success(item);
    }

    public Result<bool> Submit()
    {
        if (_items.Count == 0)
            return Result<bool>.Failure(
                Error.Validation("Order.Empty", "Cannot submit an empty order"));

        Status = OrderStatus.Submitted;
        return Result<bool>.Success(true);
    }

    private void RecalculateTotal()
    {
        Total = _items.Sum(i => i.Quantity * i.UnitPrice);
    }
}

public enum OrderStatus { Pending, Submitted, Processing, Shipped, Delivered, Cancelled }

Anti-Patterns to Avoid

  1. ❌ Throwing exceptions for validation/business logic β€” use Result pattern
  2. ❌ Anemic domain models (entities with only properties) β€” put behavior in entities
  3. ❌ Fat controllers/endpoints β€” delegate to MediatR handlers
  4. ❌ .Result or .Wait() on async calls β€” async all the way
  5. ❌ Returning IQueryable from repositories β€” materialize queries in the handler
  6. ❌ Using AutoMapper for simple mappings β€” manual mapping or extension methods
  7. ❌ Catching Exception broadly β€” catch specific exceptions at infrastructure boundaries
  8. ❌ Hard-coding connection strings β€” use IConfiguration and environment variables
  9. ❌ Missing CancellationToken β€” pass it through the entire call chain
  10. ❌ Using DbContext without AsNoTracking() for read queries

# Supported AI Coding Agents

This skill is compatible with the SKILL.md standard and works with all major AI coding agents:

Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.