mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 09:37:15 +00:00
feat: add .NET backend development plugin (#157)
Co-authored-by: Martineto21 <ramac21@gmail.com>
This commit is contained in:
committed by
GitHub
parent
f95810b340
commit
c81daa055d
@@ -0,0 +1,523 @@
|
||||
// Repository Implementation Template for .NET 8+
|
||||
// Demonstrates both Dapper (performance) and EF Core (convenience) patterns
|
||||
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace YourNamespace.Infrastructure.Data;
|
||||
|
||||
#region Interfaces
|
||||
|
||||
public interface IProductRepository
|
||||
{
|
||||
Task<Product?> GetByIdAsync(string id, CancellationToken ct = default);
|
||||
Task<Product?> GetBySkuAsync(string sku, CancellationToken ct = default);
|
||||
Task<(IReadOnlyList<Product> Items, int TotalCount)> SearchAsync(ProductSearchRequest request, CancellationToken ct = default);
|
||||
Task<Product> CreateAsync(Product product, CancellationToken ct = default);
|
||||
Task<Product> UpdateAsync(Product product, CancellationToken ct = default);
|
||||
Task DeleteAsync(string id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Product>> GetByIdsAsync(IEnumerable<string> ids, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dapper Implementation (High Performance)
|
||||
|
||||
public class DapperProductRepository : IProductRepository
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
private readonly ILogger<DapperProductRepository> _logger;
|
||||
|
||||
public DapperProductRepository(
|
||||
IDbConnection connection,
|
||||
ILogger<DapperProductRepository> logger)
|
||||
{
|
||||
_connection = connection;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, UpdatedAt
|
||||
FROM Products
|
||||
WHERE Id = @Id AND IsDeleted = 0
|
||||
""";
|
||||
|
||||
return await _connection.QueryFirstOrDefaultAsync<Product>(
|
||||
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
|
||||
}
|
||||
|
||||
public async Task<Product?> GetBySkuAsync(string sku, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, UpdatedAt
|
||||
FROM Products
|
||||
WHERE Sku = @Sku AND IsDeleted = 0
|
||||
""";
|
||||
|
||||
return await _connection.QueryFirstOrDefaultAsync<Product>(
|
||||
new CommandDefinition(sql, new { Sku = sku }, cancellationToken: ct));
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<Product> Items, int TotalCount)> SearchAsync(
|
||||
ProductSearchRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var whereClauses = new List<string> { "IsDeleted = 0" };
|
||||
var parameters = new DynamicParameters();
|
||||
|
||||
// Build dynamic WHERE clause
|
||||
if (!string.IsNullOrWhiteSpace(request.SearchTerm))
|
||||
{
|
||||
whereClauses.Add("(Name LIKE @SearchTerm OR Sku LIKE @SearchTerm)");
|
||||
parameters.Add("SearchTerm", $"%{request.SearchTerm}%");
|
||||
}
|
||||
|
||||
if (request.CategoryId.HasValue)
|
||||
{
|
||||
whereClauses.Add("CategoryId = @CategoryId");
|
||||
parameters.Add("CategoryId", request.CategoryId.Value);
|
||||
}
|
||||
|
||||
if (request.MinPrice.HasValue)
|
||||
{
|
||||
whereClauses.Add("Price >= @MinPrice");
|
||||
parameters.Add("MinPrice", request.MinPrice.Value);
|
||||
}
|
||||
|
||||
if (request.MaxPrice.HasValue)
|
||||
{
|
||||
whereClauses.Add("Price <= @MaxPrice");
|
||||
parameters.Add("MaxPrice", request.MaxPrice.Value);
|
||||
}
|
||||
|
||||
var whereClause = string.Join(" AND ", whereClauses);
|
||||
var page = request.Page ?? 1;
|
||||
var pageSize = request.PageSize ?? 50;
|
||||
var offset = (page - 1) * pageSize;
|
||||
|
||||
parameters.Add("Offset", offset);
|
||||
parameters.Add("PageSize", pageSize);
|
||||
|
||||
// Use multi-query for count + data in single roundtrip
|
||||
var sql = $"""
|
||||
-- Count query
|
||||
SELECT COUNT(*) FROM Products WHERE {whereClause};
|
||||
|
||||
-- Data query with pagination
|
||||
SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, UpdatedAt
|
||||
FROM Products
|
||||
WHERE {whereClause}
|
||||
ORDER BY Name
|
||||
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;
|
||||
""";
|
||||
|
||||
using var multi = await _connection.QueryMultipleAsync(
|
||||
new CommandDefinition(sql, parameters, cancellationToken: ct));
|
||||
|
||||
var totalCount = await multi.ReadSingleAsync<int>();
|
||||
var items = (await multi.ReadAsync<Product>()).ToList();
|
||||
|
||||
return (items, totalCount);
|
||||
}
|
||||
|
||||
public async Task<Product> CreateAsync(Product product, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO Products (Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, IsDeleted)
|
||||
VALUES (@Id, @Name, @Sku, @Price, @CategoryId, @Stock, @CreatedAt, 0);
|
||||
|
||||
SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, UpdatedAt
|
||||
FROM Products WHERE Id = @Id;
|
||||
""";
|
||||
|
||||
return await _connection.QuerySingleAsync<Product>(
|
||||
new CommandDefinition(sql, product, cancellationToken: ct));
|
||||
}
|
||||
|
||||
public async Task<Product> UpdateAsync(Product product, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE Products
|
||||
SET Name = @Name,
|
||||
Sku = @Sku,
|
||||
Price = @Price,
|
||||
CategoryId = @CategoryId,
|
||||
Stock = @Stock,
|
||||
UpdatedAt = @UpdatedAt
|
||||
WHERE Id = @Id AND IsDeleted = 0;
|
||||
|
||||
SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, UpdatedAt
|
||||
FROM Products WHERE Id = @Id;
|
||||
""";
|
||||
|
||||
return await _connection.QuerySingleAsync<Product>(
|
||||
new CommandDefinition(sql, product, cancellationToken: ct));
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE Products
|
||||
SET IsDeleted = 1, UpdatedAt = @UpdatedAt
|
||||
WHERE Id = @Id
|
||||
""";
|
||||
|
||||
await _connection.ExecuteAsync(
|
||||
new CommandDefinition(sql, new { Id = id, UpdatedAt = DateTime.UtcNow }, cancellationToken: ct));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Product>> GetByIdsAsync(
|
||||
IEnumerable<string> ids,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var idList = ids.ToList();
|
||||
if (idList.Count == 0)
|
||||
return Array.Empty<Product>();
|
||||
|
||||
const string sql = """
|
||||
SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt, UpdatedAt
|
||||
FROM Products
|
||||
WHERE Id IN @Ids AND IsDeleted = 0
|
||||
""";
|
||||
|
||||
var results = await _connection.QueryAsync<Product>(
|
||||
new CommandDefinition(sql, new { Ids = idList }, cancellationToken: ct));
|
||||
|
||||
return results.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EF Core Implementation (Rich Domain Models)
|
||||
|
||||
public class EfCoreProductRepository : IProductRepository
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILogger<EfCoreProductRepository> _logger;
|
||||
|
||||
public EfCoreProductRepository(
|
||||
AppDbContext context,
|
||||
ILogger<EfCoreProductRepository> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Products
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Id == id, ct);
|
||||
}
|
||||
|
||||
public async Task<Product?> GetBySkuAsync(string sku, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Products
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Sku == sku, ct);
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<Product> Items, int TotalCount)> SearchAsync(
|
||||
ProductSearchRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var query = _context.Products.AsNoTracking();
|
||||
|
||||
// Apply filters
|
||||
if (!string.IsNullOrWhiteSpace(request.SearchTerm))
|
||||
{
|
||||
var term = request.SearchTerm.ToLower();
|
||||
query = query.Where(p =>
|
||||
p.Name.ToLower().Contains(term) ||
|
||||
p.Sku.ToLower().Contains(term));
|
||||
}
|
||||
|
||||
if (request.CategoryId.HasValue)
|
||||
query = query.Where(p => p.CategoryId == request.CategoryId.Value);
|
||||
|
||||
if (request.MinPrice.HasValue)
|
||||
query = query.Where(p => p.Price >= request.MinPrice.Value);
|
||||
|
||||
if (request.MaxPrice.HasValue)
|
||||
query = query.Where(p => p.Price <= request.MaxPrice.Value);
|
||||
|
||||
// Get count before pagination
|
||||
var totalCount = await query.CountAsync(ct);
|
||||
|
||||
// Apply pagination
|
||||
var page = request.Page ?? 1;
|
||||
var pageSize = request.PageSize ?? 50;
|
||||
|
||||
var items = await query
|
||||
.OrderBy(p => p.Name)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return (items, totalCount);
|
||||
}
|
||||
|
||||
public async Task<Product> CreateAsync(Product product, CancellationToken ct = default)
|
||||
{
|
||||
_context.Products.Add(product);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return product;
|
||||
}
|
||||
|
||||
public async Task<Product> UpdateAsync(Product product, CancellationToken ct = default)
|
||||
{
|
||||
_context.Products.Update(product);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return product;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
var product = await _context.Products.FindAsync(new object[] { id }, ct);
|
||||
if (product != null)
|
||||
{
|
||||
product.IsDeleted = true;
|
||||
product.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Product>> GetByIdsAsync(
|
||||
IEnumerable<string> ids,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var idList = ids.ToList();
|
||||
if (idList.Count == 0)
|
||||
return Array.Empty<Product>();
|
||||
|
||||
return await _context.Products
|
||||
.AsNoTracking()
|
||||
.Where(p => idList.Contains(p.Id))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DbContext Configuration
|
||||
|
||||
public class AppDbContext : DbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Product> Products => Set<Product>();
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
public DbSet<Order> Orders => Set<Order>();
|
||||
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// Apply all configurations from assembly
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
|
||||
|
||||
// Global query filter for soft delete
|
||||
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
|
||||
}
|
||||
}
|
||||
|
||||
public class ProductConfiguration : IEntityTypeConfiguration<Product>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Product> builder)
|
||||
{
|
||||
builder.ToTable("Products");
|
||||
|
||||
builder.HasKey(p => p.Id);
|
||||
builder.Property(p => p.Id).HasMaxLength(40);
|
||||
|
||||
builder.Property(p => p.Name)
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(p => p.Sku)
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(p => p.Price)
|
||||
.HasPrecision(18, 2);
|
||||
|
||||
// Indexes
|
||||
builder.HasIndex(p => p.Sku).IsUnique();
|
||||
builder.HasIndex(p => p.CategoryId);
|
||||
builder.HasIndex(p => new { p.CategoryId, p.Name });
|
||||
|
||||
// Relationships
|
||||
builder.HasOne(p => p.Category)
|
||||
.WithMany(c => c.Products)
|
||||
.HasForeignKey(p => p.CategoryId);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Advanced Patterns
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Work pattern for coordinating multiple repositories
|
||||
/// </summary>
|
||||
public interface IUnitOfWork : IDisposable
|
||||
{
|
||||
IProductRepository Products { get; }
|
||||
IOrderRepository Orders { get; }
|
||||
Task<int> SaveChangesAsync(CancellationToken ct = default);
|
||||
Task BeginTransactionAsync(CancellationToken ct = default);
|
||||
Task CommitAsync(CancellationToken ct = default);
|
||||
Task RollbackAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class UnitOfWork : IUnitOfWork
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private IDbContextTransaction? _transaction;
|
||||
|
||||
public IProductRepository Products { get; }
|
||||
public IOrderRepository Orders { get; }
|
||||
|
||||
public UnitOfWork(
|
||||
AppDbContext context,
|
||||
IProductRepository products,
|
||||
IOrderRepository orders)
|
||||
{
|
||||
_context = context;
|
||||
Products = products;
|
||||
Orders = orders;
|
||||
}
|
||||
|
||||
public async Task<int> SaveChangesAsync(CancellationToken ct = default)
|
||||
=> await _context.SaveChangesAsync(ct);
|
||||
|
||||
public async Task BeginTransactionAsync(CancellationToken ct = default)
|
||||
{
|
||||
_transaction = await _context.Database.BeginTransactionAsync(ct);
|
||||
}
|
||||
|
||||
public async Task CommitAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_transaction != null)
|
||||
{
|
||||
await _transaction.CommitAsync(ct);
|
||||
await _transaction.DisposeAsync();
|
||||
_transaction = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RollbackAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_transaction != null)
|
||||
{
|
||||
await _transaction.RollbackAsync(ct);
|
||||
await _transaction.DisposeAsync();
|
||||
_transaction = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_transaction?.Dispose();
|
||||
_context.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specification pattern for complex queries
|
||||
/// </summary>
|
||||
public interface ISpecification<T>
|
||||
{
|
||||
Expression<Func<T, bool>> Criteria { get; }
|
||||
List<Expression<Func<T, object>>> Includes { get; }
|
||||
List<string> IncludeStrings { get; }
|
||||
Expression<Func<T, object>>? OrderBy { get; }
|
||||
Expression<Func<T, object>>? OrderByDescending { get; }
|
||||
int? Take { get; }
|
||||
int? Skip { get; }
|
||||
}
|
||||
|
||||
public abstract class BaseSpecification<T> : ISpecification<T>
|
||||
{
|
||||
public Expression<Func<T, bool>> Criteria { get; private set; } = _ => true;
|
||||
public List<Expression<Func<T, object>>> Includes { get; } = new();
|
||||
public List<string> IncludeStrings { get; } = new();
|
||||
public Expression<Func<T, object>>? OrderBy { get; private set; }
|
||||
public Expression<Func<T, object>>? OrderByDescending { get; private set; }
|
||||
public int? Take { get; private set; }
|
||||
public int? Skip { get; private set; }
|
||||
|
||||
protected void AddCriteria(Expression<Func<T, bool>> criteria) => Criteria = criteria;
|
||||
protected void AddInclude(Expression<Func<T, object>> include) => Includes.Add(include);
|
||||
protected void AddInclude(string include) => IncludeStrings.Add(include);
|
||||
protected void ApplyOrderBy(Expression<Func<T, object>> orderBy) => OrderBy = orderBy;
|
||||
protected void ApplyOrderByDescending(Expression<Func<T, object>> orderBy) => OrderByDescending = orderBy;
|
||||
protected void ApplyPaging(int skip, int take) { Skip = skip; Take = take; }
|
||||
}
|
||||
|
||||
// Example specification
|
||||
public class ProductsByCategorySpec : BaseSpecification<Product>
|
||||
{
|
||||
public ProductsByCategorySpec(int categoryId, int page, int pageSize)
|
||||
{
|
||||
AddCriteria(p => p.CategoryId == categoryId);
|
||||
AddInclude(p => p.Category);
|
||||
ApplyOrderBy(p => p.Name);
|
||||
ApplyPaging((page - 1) * pageSize, pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Entity Definitions
|
||||
|
||||
public class Product
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Sku { get; set; } = string.Empty;
|
||||
public decimal Price { get; set; }
|
||||
public int CategoryId { get; set; }
|
||||
public int Stock { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
// Navigation
|
||||
public Category? Category { get; set; }
|
||||
}
|
||||
|
||||
public class Category
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public ICollection<Product> Products { get; set; } = new List<Product>();
|
||||
}
|
||||
|
||||
public class Order
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string CustomerOrderCode { get; set; } = string.Empty;
|
||||
public decimal Total { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
|
||||
}
|
||||
|
||||
public class OrderItem
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int OrderId { get; set; }
|
||||
public string ProductId { get; set; } = string.Empty;
|
||||
public int Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
public Order? Order { get; set; }
|
||||
public Product? Product { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,336 @@
|
||||
// Service Implementation Template for .NET 8+
|
||||
// This template demonstrates best practices for building robust services
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace YourNamespace.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the service
|
||||
/// </summary>
|
||||
public class ProductServiceOptions
|
||||
{
|
||||
public const string SectionName = "ProductService";
|
||||
|
||||
public int DefaultPageSize { get; set; } = 50;
|
||||
public int MaxPageSize { get; set; } = 200;
|
||||
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
public bool EnableEnrichment { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic result type for operations that can fail
|
||||
/// </summary>
|
||||
public class Result<T>
|
||||
{
|
||||
public bool IsSuccess { get; }
|
||||
public T? Value { get; }
|
||||
public string? Error { get; }
|
||||
public string? ErrorCode { get; }
|
||||
|
||||
private Result(bool isSuccess, T? value, string? error, string? errorCode)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
Value = value;
|
||||
Error = error;
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public static Result<T> Success(T value) => new(true, value, null, null);
|
||||
public static Result<T> Failure(string error, string? code = null) => new(false, default, error, code);
|
||||
|
||||
public Result<TNew> Map<TNew>(Func<T, TNew> mapper) =>
|
||||
IsSuccess ? Result<TNew>.Success(mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service interface - define the contract
|
||||
/// </summary>
|
||||
public interface IProductService
|
||||
{
|
||||
Task<Result<Product>> GetByIdAsync(string id, CancellationToken ct = default);
|
||||
Task<Result<PagedResult<Product>>> SearchAsync(ProductSearchRequest request, CancellationToken ct = default);
|
||||
Task<Result<Product>> CreateAsync(CreateProductRequest request, CancellationToken ct = default);
|
||||
Task<Result<Product>> UpdateAsync(string id, UpdateProductRequest request, CancellationToken ct = default);
|
||||
Task<Result<bool>> DeleteAsync(string id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation with full patterns
|
||||
/// </summary>
|
||||
public class ProductService : IProductService
|
||||
{
|
||||
private readonly IProductRepository _repository;
|
||||
private readonly ICacheService _cache;
|
||||
private readonly IValidator<CreateProductRequest> _createValidator;
|
||||
private readonly IValidator<UpdateProductRequest> _updateValidator;
|
||||
private readonly ILogger<ProductService> _logger;
|
||||
private readonly ProductServiceOptions _options;
|
||||
|
||||
public ProductService(
|
||||
IProductRepository repository,
|
||||
ICacheService cache,
|
||||
IValidator<CreateProductRequest> createValidator,
|
||||
IValidator<UpdateProductRequest> updateValidator,
|
||||
ILogger<ProductService> logger,
|
||||
IOptions<ProductServiceOptions> options)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_createValidator = createValidator ?? throw new ArgumentNullException(nameof(createValidator));
|
||||
_updateValidator = updateValidator ?? throw new ArgumentNullException(nameof(updateValidator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public async Task<Result<Product>> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return Result<Product>.Failure("Product ID is required", "INVALID_ID");
|
||||
|
||||
try
|
||||
{
|
||||
// Try cache first
|
||||
var cacheKey = GetCacheKey(id);
|
||||
var cached = await _cache.GetAsync<Product>(cacheKey, ct);
|
||||
|
||||
if (cached != null)
|
||||
{
|
||||
_logger.LogDebug("Cache hit for product {ProductId}", id);
|
||||
return Result<Product>.Success(cached);
|
||||
}
|
||||
|
||||
// Fetch from repository
|
||||
var product = await _repository.GetByIdAsync(id, ct);
|
||||
|
||||
if (product == null)
|
||||
{
|
||||
_logger.LogWarning("Product not found: {ProductId}", id);
|
||||
return Result<Product>.Failure($"Product '{id}' not found", "NOT_FOUND");
|
||||
}
|
||||
|
||||
// Populate cache
|
||||
await _cache.SetAsync(cacheKey, product, _options.CacheDuration, ct);
|
||||
|
||||
return Result<Product>.Success(product);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving product {ProductId}", id);
|
||||
return Result<Product>.Failure("An error occurred while retrieving the product", "INTERNAL_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<PagedResult<Product>>> SearchAsync(
|
||||
ProductSearchRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Sanitize pagination
|
||||
var pageSize = Math.Clamp(request.PageSize ?? _options.DefaultPageSize, 1, _options.MaxPageSize);
|
||||
var page = Math.Max(request.Page ?? 1, 1);
|
||||
|
||||
var sanitizedRequest = request with
|
||||
{
|
||||
PageSize = pageSize,
|
||||
Page = page
|
||||
};
|
||||
|
||||
// Execute search
|
||||
var (items, totalCount) = await _repository.SearchAsync(sanitizedRequest, ct);
|
||||
|
||||
var result = new PagedResult<Product>
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalPages = (int)Math.Ceiling((double)totalCount / pageSize)
|
||||
};
|
||||
|
||||
return Result<PagedResult<Product>>.Success(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching products with request {@Request}", request);
|
||||
return Result<PagedResult<Product>>.Failure("An error occurred while searching products", "INTERNAL_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<Product>> CreateAsync(CreateProductRequest request, CancellationToken ct = default)
|
||||
{
|
||||
// Validate
|
||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errors = string.Join("; ", validation.Errors.Select(e => e.ErrorMessage));
|
||||
return Result<Product>.Failure(errors, "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Check for duplicates
|
||||
var existing = await _repository.GetBySkuAsync(request.Sku, ct);
|
||||
if (existing != null)
|
||||
return Result<Product>.Failure($"Product with SKU '{request.Sku}' already exists", "DUPLICATE_SKU");
|
||||
|
||||
// Create entity
|
||||
var product = new Product
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Name = request.Name,
|
||||
Sku = request.Sku,
|
||||
Price = request.Price,
|
||||
CategoryId = request.CategoryId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Persist
|
||||
var created = await _repository.CreateAsync(product, ct);
|
||||
|
||||
_logger.LogInformation("Created product {ProductId} with SKU {Sku}", created.Id, created.Sku);
|
||||
|
||||
return Result<Product>.Success(created);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating product with SKU {Sku}", request.Sku);
|
||||
return Result<Product>.Failure("An error occurred while creating the product", "INTERNAL_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<Product>> UpdateAsync(
|
||||
string id,
|
||||
UpdateProductRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return Result<Product>.Failure("Product ID is required", "INVALID_ID");
|
||||
|
||||
// Validate
|
||||
var validation = await _updateValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errors = string.Join("; ", validation.Errors.Select(e => e.ErrorMessage));
|
||||
return Result<Product>.Failure(errors, "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Fetch existing
|
||||
var existing = await _repository.GetByIdAsync(id, ct);
|
||||
if (existing == null)
|
||||
return Result<Product>.Failure($"Product '{id}' not found", "NOT_FOUND");
|
||||
|
||||
// Apply updates (only non-null values)
|
||||
if (request.Name != null) existing.Name = request.Name;
|
||||
if (request.Price.HasValue) existing.Price = request.Price.Value;
|
||||
if (request.CategoryId.HasValue) existing.CategoryId = request.CategoryId.Value;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Persist
|
||||
var updated = await _repository.UpdateAsync(existing, ct);
|
||||
|
||||
// Invalidate cache
|
||||
await _cache.RemoveAsync(GetCacheKey(id), ct);
|
||||
|
||||
_logger.LogInformation("Updated product {ProductId}", id);
|
||||
|
||||
return Result<Product>.Success(updated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating product {ProductId}", id);
|
||||
return Result<Product>.Failure("An error occurred while updating the product", "INTERNAL_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<bool>> DeleteAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return Result<bool>.Failure("Product ID is required", "INVALID_ID");
|
||||
|
||||
try
|
||||
{
|
||||
var existing = await _repository.GetByIdAsync(id, ct);
|
||||
if (existing == null)
|
||||
return Result<bool>.Failure($"Product '{id}' not found", "NOT_FOUND");
|
||||
|
||||
// Soft delete
|
||||
await _repository.DeleteAsync(id, ct);
|
||||
|
||||
// Invalidate cache
|
||||
await _cache.RemoveAsync(GetCacheKey(id), ct);
|
||||
|
||||
_logger.LogInformation("Deleted product {ProductId}", id);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting product {ProductId}", id);
|
||||
return Result<bool>.Failure("An error occurred while deleting the product", "INTERNAL_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetCacheKey(string id) => $"product:{id}";
|
||||
}
|
||||
|
||||
// Supporting types
|
||||
public record CreateProductRequest(string Name, string Sku, decimal Price, int CategoryId);
|
||||
public record UpdateProductRequest(string? Name = null, decimal? Price = null, int? CategoryId = null);
|
||||
public record ProductSearchRequest(
|
||||
string? SearchTerm = null,
|
||||
int? CategoryId = null,
|
||||
decimal? MinPrice = null,
|
||||
decimal? MaxPrice = null,
|
||||
int? Page = null,
|
||||
int? PageSize = null);
|
||||
|
||||
public class PagedResult<T>
|
||||
{
|
||||
public IReadOnlyList<T> Items { get; init; } = Array.Empty<T>();
|
||||
public int TotalCount { get; init; }
|
||||
public int Page { get; init; }
|
||||
public int PageSize { get; init; }
|
||||
public int TotalPages { get; init; }
|
||||
public bool HasNextPage => Page < TotalPages;
|
||||
public bool HasPreviousPage => Page > 1;
|
||||
}
|
||||
|
||||
public class Product
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Sku { get; set; } = string.Empty;
|
||||
public decimal Price { get; set; }
|
||||
public int CategoryId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
// Validators using FluentValidation
|
||||
public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>
|
||||
{
|
||||
public CreateProductRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Name is required")
|
||||
.MaximumLength(200).WithMessage("Name must not exceed 200 characters");
|
||||
|
||||
RuleFor(x => x.Sku)
|
||||
.NotEmpty().WithMessage("SKU is required")
|
||||
.MaximumLength(50).WithMessage("SKU must not exceed 50 characters")
|
||||
.Matches(@"^[A-Z0-9\-]+$").WithMessage("SKU must contain only uppercase letters, numbers, and hyphens");
|
||||
|
||||
RuleFor(x => x.Price)
|
||||
.GreaterThan(0).WithMessage("Price must be greater than 0");
|
||||
|
||||
RuleFor(x => x.CategoryId)
|
||||
.GreaterThan(0).WithMessage("Category is required");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user