Compare commits

...

8 Commits

Author SHA1 Message Date
OlgunR
45e5327148 Add ICatalogRepository interface and design comments
Added ICatalogRepository with async catalog retrieval methods. Included detailed comments on repository design patterns and clean architecture layer placement, recommending interface stays in Domain. Fixed file encoding (added BOM).
2026-01-19 09:12:16 +01:00
OlgunR
9387db9824 Add comments on generic repo pattern limitations
Added Copilot comments explaining why a generic repository is unsuitable due to stored procedure complexities and asymmetric CRUD operations. Suggested keeping specialized repositories and extracting helpers for reuse. Also added a TODO to move the interface to the application layer for clean architecture.
2026-01-19 09:07:41 +01:00
OlgunR
ef76599bce Document rationale against generic CRUD/service pattern
Added detailed comments in CatalogService and ICatalogService explaining why a generic CRUD base service or repository is not suitable for this solution, due to entity-specific domain logic and stored procedure usage. Removed the previous Copilot comment from CatalogRepository. No functional changes; updates are for architectural clarity.
2026-01-19 09:02:29 +01:00
OlgunR
870b10779e Refactor Catalogs to use MediatR and CQRS pattern
Replaced direct service usage in CatalogsController with MediatR-based commands and queries for all CRUD operations. Added command/query and handler classes for Catalog operations. Updated dependency injection to register MediatR and removed ICatalogService. Improved code maintainability and testability by adopting CQRS architecture.
2026-01-19 09:00:06 +01:00
OlgunR
c8c75b1dc5 Expand usings; add note on generic vs per-entity repos
Expanded using directives to support additional dependencies in CatalogRepository.cs. Added a detailed comment explaining why per-entity repository implementations are preferred over a generic CRUD base service in this context, due to unique domain logic and stored procedure requirements. No functional code changes were made.
2026-01-19 08:53:25 +01:00
OlgunR
8c31784a5a Refactor DI setup with extension methods for modularity
Refactored dependency injection by introducing AddApplication and AddInfrastructure extension methods for service registration. Moved DbContext and AutoMapper setup out of Program.cs to improve modularity and reusability. Added required NuGet packages to .csproj files.
2026-01-19 08:46:52 +01:00
OlgunR
28bab05980 Add configurable CORS support via appsettings.json
Introduce CORS configuration using allowed origins from appsettings.json. Updated Program.cs to read allowed origins from configuration and apply them to the CORS policy, defaulting to AllowAnyOrigin if none are specified. Also made minor formatting and comment improvements.
2026-01-19 08:34:40 +01:00
OlgunR
289dba9b16 Add global exception handling middleware to API
Introduced ExceptionHandlingMiddleware to catch and log unhandled exceptions, returning standardized JSON error responses. Registered the middleware in the request pipeline. Also made minor formatting and comment improvements in Program.cs and ICatalogRepository.cs.
2026-01-19 08:31:10 +01:00
23 changed files with 361 additions and 25 deletions

View File

@@ -1,4 +1,7 @@
using DbFirst.Application.Catalogs;
using DbFirst.Application.Catalogs.Commands;
using DbFirst.Application.Catalogs.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DbFirst.API.Controllers;
@@ -7,24 +10,24 @@ namespace DbFirst.API.Controllers;
[Route("api/[controller]")]
public class CatalogsController : ControllerBase
{
private readonly ICatalogService _service;
private readonly IMediator _mediator;
public CatalogsController(ICatalogService service)
public CatalogsController(IMediator mediator)
{
_service = service;
_mediator = mediator;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<CatalogReadDto>>> GetAll(CancellationToken cancellationToken)
{
var result = await _service.GetAllAsync(cancellationToken);
var result = await _mediator.Send(new GetAllCatalogsQuery(), cancellationToken);
return Ok(result);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<CatalogReadDto>> GetById(int id, CancellationToken cancellationToken)
{
var result = await _service.GetByIdAsync(id, cancellationToken);
var result = await _mediator.Send(new GetCatalogByIdQuery(id), cancellationToken);
if (result == null)
{
return NotFound();
@@ -35,7 +38,7 @@ public class CatalogsController : ControllerBase
[HttpPost]
public async Task<ActionResult<CatalogReadDto>> Create(CatalogWriteDto dto, CancellationToken cancellationToken)
{
var created = await _service.CreateAsync(dto, cancellationToken);
var created = await _mediator.Send(new CreateCatalogCommand(dto), cancellationToken);
if (created == null)
{
return Conflict();
@@ -46,7 +49,7 @@ public class CatalogsController : ControllerBase
[HttpPut("{id:int}")]
public async Task<ActionResult<CatalogReadDto>> Update(int id, CatalogWriteDto dto, CancellationToken cancellationToken)
{
var current = await _service.GetByIdAsync(id, cancellationToken);
var current = await _mediator.Send(new GetCatalogByIdQuery(id), cancellationToken);
if (current == null)
{
return NotFound();
@@ -56,7 +59,7 @@ public class CatalogsController : ControllerBase
return BadRequest("CatTitle cannot be changed.");
}
var updated = await _service.UpdateAsync(id, dto, cancellationToken);
var updated = await _mediator.Send(new UpdateCatalogCommand(id, dto), cancellationToken);
if (updated == null)
{
return NotFound();
@@ -67,7 +70,7 @@ public class CatalogsController : ControllerBase
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
var deleted = await _service.DeleteAsync(id, cancellationToken);
var deleted = await _mediator.Send(new DeleteCatalogCommand(id), cancellationToken);
if (!deleted)
{
return NotFound();

View File

@@ -16,6 +16,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,52 @@
using System.Net;
using System.Text.Json;
namespace DbFirst.API.Middleware;
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
await WriteProblemDetailsAsync(context, ex);
}
}
private static async Task WriteProblemDetailsAsync(HttpContext context, Exception ex)
{
if (context.Response.HasStarted)
{
throw ex;
}
context.Response.Clear();
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.ContentType = "application/json";
var problem = new
{
type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
title = "Serverfehler",
status = context.Response.StatusCode,
detail = ex.Message,
traceId = context.TraceIdentifier
};
await context.Response.WriteAsync(JsonSerializer.Serialize(problem));
}
}

View File

@@ -1,10 +1,10 @@
using DbFirst.Application;
using DbFirst.Application.Catalogs;
using DbFirst.Domain.Repositories;
using DbFirst.Infrastructure;
using DbFirst.Infrastructure.Repositories;
using Microsoft.EntityFrameworkCore;
//TODO: create and add exception handling middleware
using MediatR;
using DbFirst.API.Middleware;
var builder = WebApplication.CreateBuilder(args);
@@ -16,23 +16,28 @@ builder.Services.AddSwaggerGen();
// TODO: allow listed origins configured in appsettings.json
builder.Services.AddCors(options =>
{
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
if (origins.Length > 0)
{
policy.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod();
}
else
{
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
}
});
});
// TODO: Create extension method for this in Infrastructure layer in case of using in multiple projects
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// TODO: Create extension method for this in Application layer in case of using in multiple projects
builder.Services.AddAutoMapper(typeof(CatalogProfile).Assembly, typeof(ApplicationDbContext).Assembly);
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddApplication();
builder.Services.AddScoped<ICatalogRepository, CatalogRepository>();
builder.Services.AddScoped<ICatalogService, CatalogService>();
var app = builder.Build();
@@ -43,6 +48,8 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthorization();

View File

@@ -2,6 +2,12 @@
"ConnectionStrings": {
"DefaultConnection": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;"
},
"Cors": {
"AllowedOrigins": [
"https://localhost:7276",
"http://localhost:5101"
]
},
"Logging": {
"LogLevel": {
"Default": "Information",

View File

@@ -5,6 +5,22 @@ using DbFirst.Domain.Entities;
namespace DbFirst.Application.Catalogs;
//TODO: create generic service to reduce code duplication
/* Copilot's Response:
A generic CRUD base service adds little value in your case:
Pros:
• Less boilerplate for simple entities without special logic.
• Uniform CRUD signatures.
Cons/Practical here:
• Domain logic differs per entity(unique title check, setting audit fields, forbidding title changes, stored procs with output GUID).
• Generic services tend to be diluted by virtual methods/hooks for special cases—ending up with per-entity overrides and little real gain.
• With stored procedures and output parameters, the pattern doesnt fit cleanly because operations arent symmetric (separate procs for insert/update/delete).
Conclusion: For this solution a generic service would be more overhead than benefit. If you later have multiple very similar entities without special logic,
you could consider a lightweight generic interface/base; for now, the specialized service implementation is cleaner. */
//TODO: implement CQRS pattern with MediatR
public class CatalogService : ICatalogService
{

View File

@@ -0,0 +1,6 @@
using DbFirst.Application.Catalogs;
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;
public record CreateCatalogCommand(CatalogWriteDto Dto) : IRequest<CatalogReadDto?>;

View File

@@ -0,0 +1,36 @@
using AutoMapper;
using DbFirst.Domain.Entities;
using DbFirst.Domain.Repositories;
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;
public class CreateCatalogHandler : IRequestHandler<CreateCatalogCommand, CatalogReadDto?>
{
private readonly ICatalogRepository _repository;
private readonly IMapper _mapper;
public CreateCatalogHandler(ICatalogRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<CatalogReadDto?> Handle(CreateCatalogCommand request, CancellationToken cancellationToken)
{
var existing = await _repository.GetByTitleAsync(request.Dto.CatTitle, cancellationToken);
if (existing != null)
{
return null;
}
var entity = _mapper.Map<VwmyCatalog>(request.Dto);
entity.AddedWho = "system";
entity.AddedWhen = DateTime.UtcNow;
entity.ChangedWho = "system";
entity.ChangedWhen = DateTime.UtcNow;
var created = await _repository.InsertAsync(entity, cancellationToken);
return _mapper.Map<CatalogReadDto>(created);
}
}

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;
public record DeleteCatalogCommand(int Id) : IRequest<bool>;

View File

@@ -0,0 +1,19 @@
using DbFirst.Domain.Repositories;
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;
public class DeleteCatalogHandler : IRequestHandler<DeleteCatalogCommand, bool>
{
private readonly ICatalogRepository _repository;
public DeleteCatalogHandler(ICatalogRepository repository)
{
_repository = repository;
}
public async Task<bool> Handle(DeleteCatalogCommand request, CancellationToken cancellationToken)
{
return await _repository.DeleteAsync(request.Id, cancellationToken);
}
}

View File

@@ -0,0 +1,6 @@
using DbFirst.Application.Catalogs;
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;
public record UpdateCatalogCommand(int Id, CatalogWriteDto Dto) : IRequest<CatalogReadDto?>;

View File

@@ -0,0 +1,38 @@
using AutoMapper;
using DbFirst.Domain.Entities;
using DbFirst.Domain.Repositories;
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;
public class UpdateCatalogHandler : IRequestHandler<UpdateCatalogCommand, CatalogReadDto?>
{
private readonly ICatalogRepository _repository;
private readonly IMapper _mapper;
public UpdateCatalogHandler(ICatalogRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<CatalogReadDto?> Handle(UpdateCatalogCommand request, CancellationToken cancellationToken)
{
var existing = await _repository.GetByIdAsync(request.Id, cancellationToken);
if (existing == null)
{
return null;
}
var entity = _mapper.Map<VwmyCatalog>(request.Dto);
entity.Guid = request.Id;
entity.CatTitle = existing.CatTitle;
entity.AddedWho = existing.AddedWho;
entity.AddedWhen = existing.AddedWhen;
entity.ChangedWho = "system";
entity.ChangedWhen = DateTime.UtcNow;
var updated = await _repository.UpdateAsync(request.Id, entity, cancellationToken);
return updated == null ? null : _mapper.Map<CatalogReadDto>(updated);
}
}

View File

@@ -1,6 +1,22 @@
namespace DbFirst.Application.Catalogs;
//TODO: create generic service to reduce code duplication
/* Copilot's Response:
A generic CRUD base service adds little value in your case:
Pros:
• Less boilerplate for simple entities without special logic.
• Uniform CRUD signatures.
Cons/Practical here:
• Domain logic differs per entity(unique title check, setting audit fields, forbidding title changes, stored procs with output GUID).
• Generic services tend to be diluted by virtual methods/hooks for special cases—ending up with per-entity overrides and little real gain.
• With stored procedures and output parameters, the pattern doesnt fit cleanly because operations arent symmetric (separate procs for insert/update/delete).
Conclusion: For this solution a generic service would be more overhead than benefit. If you later have multiple very similar entities without special logic,
you could consider a lightweight generic interface/base; for now, the specialized service implementation is cleaner. */
public interface ICatalogService
{
Task<List<CatalogReadDto>> GetAllAsync(CancellationToken cancellationToken = default);

View File

@@ -0,0 +1,23 @@
using AutoMapper;
using DbFirst.Domain.Repositories;
using MediatR;
namespace DbFirst.Application.Catalogs.Queries;
public class GetAllCatalogsHandler : IRequestHandler<GetAllCatalogsQuery, List<CatalogReadDto>>
{
private readonly ICatalogRepository _repository;
private readonly IMapper _mapper;
public GetAllCatalogsHandler(ICatalogRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<List<CatalogReadDto>> Handle(GetAllCatalogsQuery request, CancellationToken cancellationToken)
{
var items = await _repository.GetAllAsync(cancellationToken);
return _mapper.Map<List<CatalogReadDto>>(items);
}
}

View File

@@ -0,0 +1,6 @@
using DbFirst.Application.Catalogs;
using MediatR;
namespace DbFirst.Application.Catalogs.Queries;
public record GetAllCatalogsQuery : IRequest<List<CatalogReadDto>>;

View File

@@ -0,0 +1,23 @@
using AutoMapper;
using DbFirst.Domain.Repositories;
using MediatR;
namespace DbFirst.Application.Catalogs.Queries;
public class GetCatalogByIdHandler : IRequestHandler<GetCatalogByIdQuery, CatalogReadDto?>
{
private readonly ICatalogRepository _repository;
private readonly IMapper _mapper;
public GetCatalogByIdHandler(ICatalogRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<CatalogReadDto?> Handle(GetCatalogByIdQuery request, CancellationToken cancellationToken)
{
var item = await _repository.GetByIdAsync(request.Id, cancellationToken);
return item == null ? null : _mapper.Map<CatalogReadDto>(item);
}
}

View File

@@ -0,0 +1,6 @@
using DbFirst.Application.Catalogs;
using MediatR;
namespace DbFirst.Application.Catalogs.Queries;
public record GetCatalogByIdQuery(int Id) : IRequest<CatalogReadDto?>;

View File

@@ -8,6 +8,9 @@
<ItemGroup>
<PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,14 @@
using Microsoft.Extensions.DependencyInjection;
using MediatR;
namespace DbFirst.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddAutoMapper(typeof(DependencyInjection).Assembly);
services.AddMediatR(typeof(DependencyInjection).Assembly);
return services;
}
}

View File

@@ -1,9 +1,27 @@
using DbFirst.Domain.Entities;
using DbFirst.Domain.Entities;
namespace DbFirst.Domain.Repositories;
// TODO: instead of creating interface per entity, consider using generic repository pattern (eg. IRepository<T>) to reduce code duplication.
//TODO: move to application layer as a part of clean architecture
/* Copilot's Response:
A generic Repository<T> isnt really worthwhile here:
• Reads from the view are generic, but inserts/updates/deletes go through stored procedures with special parameters/output GUIDs.Youd need lots of exceptions/overrides—little gain.
• Operations arent symmetric (separate procs for insert/update/delete with output handling and reload), so a one-size-fits-all CRUD pattern doesnt fit well.
• Better to keep the specialized repo.If you want reuse, extract small helpers (e.g., for proc calls/output parameters/reload) instead of forcing a generic repository. */
// TODO: move to application layer as a part of clean architecture
/* Copilot's Response:
• Common: Keep the interface in the Domain layer, implementation in Infrastructure. Domain defines the data access contract, Infrastructure provides the implementation.
That matches typical DDD/Clean Architecture.
• Alternative: Move the interface to the Application layer. Then only Application depends on Infrastructure, Domain stays isolated from infra contracts.
Pro: Domain contains only models/rules.
Con: If Domain logic needs repos, it would depend on Application, weakening the layer separation.
Recommendation: Keep the interface in Domain. Its conventional, clean, and avoids a Domain -> Application dependency. Implementation stays in Infrastructure. */
public interface ICatalogRepository
{
Task<List<VwmyCatalog>> GetAllAsync(CancellationToken cancellationToken = default);

View File

@@ -15,6 +15,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace DbFirst.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
return services;
}
}

View File

@@ -1,12 +1,28 @@
using DbFirst.Domain.Repositories;
using Azure;
using DbFirst.Domain.Entities;
using DbFirst.Domain.Repositories;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Data;
using System.Diagnostics.Metrics;
using System.Text;
using System.Threading.Channels;
using static Microsoft.EntityFrameworkCore.DbLoggerCategory;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace DbFirst.Infrastructure.Repositories;
// TODO: instead of creating implementation of repository per entity, consider using generic repository pattern (eg. Repository<T>) to reduce code duplication.
/* Copilot's Response:
A generic Repository<T> isnt really worthwhile here:
• Reads from the view are generic, but inserts/updates/deletes go through stored procedures with special parameters/output GUIDs.Youd need lots of exceptions/overrides—little gain.
• Operations arent symmetric (separate procs for insert/update/delete with output handling and reload), so a one-size-fits-all CRUD pattern doesnt fit well.
• Better to keep the specialized repo.If you want reuse, extract small helpers (e.g., for proc calls/output parameters/reload) instead of forcing a generic repository. */
public class CatalogRepository : ICatalogRepository
{
private readonly ApplicationDbContext _db;