Hata Yönetimini Result Pattern ile Yönetmek

Adem Olguner
Devops Türkiye☁️ 🐧 🐳 ☸️
7 min readJan 8, 2024

Gel Ne Olursan Ol Yine Gel, ister NotFoundException, ister AlreadyException isterse InvalidOperationException bizim yolumuz Result Pattern :)

Mizahın en güçlü yanı güçsüz olmamasıdır :)

Kendi adıma öğrenme stratejisi olarak öğrendiklerimi daha hatırlanabilir kılmak için kullanıyor olabilirim bilmiyorum Result’ta göreceğiz…

Geliştirdiğimiz veya tasarladığımız tüm projelerde yada çözümlemeye çalıştığımız iş kuralları değişkenlik gösterse de alışılagelmiş hata yönetim metotlarını kullanıyoruz. Çoğu zaman projelerdeki hataları nasıl ele alırız sorusunu sormuyor ezbere olanı kullanıyoruz.

Kodumuzda ki hataları nasıl ele alıyoruz?

Bu sorunun tabiki tek bir çözümü veya tek bir cevabı yok, farklı yöntemler kullanabiliyoruz. Kod blokları içerisinde hata ile karşılaşır karşılaşmaz bir istisna (exception) fırlatıyoruz. Peki bu oluşturduğumuz veya fırlatılan istisnalar tahmin edilebilir mi? , edilemez mi? sorusunu soruyor muyuz? Gelin bu soruyu sorarak devam edelim.

İstisnaları 2 kategoriye ayırabiliriz.
- Tahmin edilebilir istisnalar (exception)
- Tahmin edilemeyen istisnalar (exception)

Bu durumda tahmin edebildiğimiz istisnalar için anlamlı, anlaşılabilir ve daha okunabilir şekilde ifade edebiliriz. Bu tarz durumlar için Result Pattern’i işlevsel bir şekilde kullanabiliriz. Bu pattern içinde negatif yanlarının bulunduğunu söyleyebilir. İşlemin başarısız olup olmadığını sorgulaması gerekmektedir. Yani iş kuralı işletildiğinde bir istisna olsa da olmasa da response kontrol eden tarafın result modelin sorgulanması gerekmektedir.

Result Pattern

Response modelimizi oluşturalım. Ben buna GenericResult olarak tanımlayacağım (keyfe keder bir tanımlama) siz istediğiniz bir isimlendirme ile tanımlayabilirsiniz.

GenericResult<T> generic modelin belirlenmesi.

using System.Net;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;

namespace ExceptionHandlingWithResultPattern.Framework.ResultPattern;

public class GenericResult<T> where T: class, new()
{
private GenericResult() : this(true)
{ }

private GenericResult(bool isSuccess)
{ IsSuccess = isSuccess; if (!isSuccess) {Data = null;} }


[JsonPropertyName("IsSuccess")]
public bool IsSuccess { get; init; }

[JsonPropertyName("Data")]
public T Data { get; private init; } = new();

[JsonPropertyName("ProblemDetails")]
public ProblemDetails ProblemDetails { get; private init; } = new();


public static GenericResult<T> Success(T data)
{
return new GenericResult<T>(true)
{
Data = data
};
}

public static GenericResult<T> Fail(string message)
{
return new GenericResult<T>(false)
{
ProblemDetails = new ProblemDetails
{
Status = (int) HttpStatusCode.BadRequest,
Title = "Operation Error",
Detail = message
}
};
}

public static GenericResult<T> Exception(ProblemDetails problemDetails)
{
return new GenericResult<T>(false)
{
ProblemDetails = problemDetails
};
}
}

GenericResult model aslında bir isSuccess, T tipinde generic model ve exceptional durum için ProblemDetails parametreleri olacaktır.

GenericResult Success ve Exception adında 2 adet metot barındırmaktadır.

* Success olan metot herhangi bir istisna olmadığı durumda geri dönecek objenin modeline göre dinamik parametreleri bulunacaktır.

* Exception metodunu ise kod içerisinde değil ExceptionHandling — OnException durumunda biz handle edeceğiz.

Tahmin edilebilir istisnaların tanımlanması

System.Exception sınıfından türeyen özel istisna sınıflarının tanımlamalarını yapacağız ve her istisna türü kendine ait bir mesaj ile son kullanıcıya anlamlı geri dönüş sağlayacaktır.

CustomBaseException adında bir istisna sınıfı oluşturup belli başlı parametrelerin tanımlamasını yapalım.

public abstract class CustomBaseException : Exception
{
public virtual string MessageFormat { get;}
public virtual string Title { get; }
public virtual HttpStatusCode StatusCode { get; }
public virtual Dictionary<string, string> MessageProps {get; set;}=new();
}

CustomBaseException sınıfından türeyen tahmin edilebilir istisnalar için özel istisna sınıfları oluşturalım.

AlreadyExistException

public sealed class AlreadyExistException : CustomBaseException
{
public override string MessageFormat => "{propName} : '{propValue}' ile bir {objectName} kaydı mevcut.";
public override string Title => "Already Exist Error";
public override HttpStatusCode StatusCode => HttpStatusCode.BadRequest;


public AlreadyExistException(string propName, string propValue, string objectName): base()
{
MessageProps.Add("{propName}", propName);
MessageProps.Add("{propValue}", propValue);
MessageProps.Add("{objectName}", objectName);
}
}

ClientSideException

public sealed class ClientSideException : CustomBaseException
{
public override string MessageFormat => "Client : {clientName} - {processName} işlemi sırasında bir hata oluştu.";
public override string Title => "Client Side Error";
public override HttpStatusCode StatusCode => HttpStatusCode.InternalServerError;

public ClientSideException(string clientName, string processName) : base()
{
MessageProps.Add("{clientName}", clientName);
MessageProps.Add("{processName}", processName);
}
}

InvalidParameterException

public sealed class InvalidParameterException : CustomBaseException
{
public override string MessageFormat => "Geçersiz parametre. {fieldName} : {fieldValue}";
public override string Title => "Invalid Parameter Error";
public override HttpStatusCode StatusCode => HttpStatusCode.BadRequest;

public InvalidParameterException(string fieldName, string fieldValue) : base()
{
MessageProps.Add("{fieldName}", fieldName);
MessageProps.Add("{fieldValue}", fieldValue);
}
}

NotFoundException

public sealed class NotFoundException : CustomBaseException
{
public override string MessageFormat => "{objectName} Kayıt bulunamadı. {propertyName}: '{propertyValue}'";
public override string Title => "Not Found Error";
public override HttpStatusCode StatusCode => HttpStatusCode.BadRequest;

public NotFoundException(string objectName, string propertyName, string propertyValue) : base()
{
MessageProps.Add("{objectName}", objectName);
MessageProps.Add("{propertyName}", propertyName);
MessageProps.Add("{propertyValue}", propertyValue);
}
}

Tahmin edilebilir istisna sınıflarını bu şekilde özel StatusCode, Title ve MessageFormat parametreleri ile tanımlama yapabiliriz. Bu standartta farklı Tahmin edilebilir istisnalar tanımlayabiliriz.

Error Handling

Şimdi sıra geldi hata yönetimini Result Pattern ile tamamlamak adımına. Kendi middleware’imizi (ExceptionHandlingMiddleware) yazmaya başlayalım. Burada yöntem olarak ister middleware yazarak isterse filter attribute (exception filter) yazabilir veya farklı bir yöntemde kullanılabilir.

using System.Net;
using System.Text.Json;
using ExceptionHandlingWithResultPattern.Framework.Exceptions.Base;
using ExceptionHandlingWithResultPattern.Framework.Exceptions.Validation;
using ExceptionHandlingWithResultPattern.Framework.ResultPattern;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace ExceptionHandlingWithResultPattern.Framework;

public class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
private const string UnexpectedErrorMessage = "Beklenmeyen bir hata ile karşılaşıldı.";

public async Task InvokeAsync(HttpContext context)
{
context.Response.ContentType = "application/json";

try
{
await next(context);
}
catch (Exception exception)
{
var messages = new List<string>();
var statusCode = (int)HttpStatusCode.InternalServerError;
var title = "Server error";

switch (exception)
{
case ArgumentValidationException validationExp:
{
statusCode = (int)ArgumentValidationException.StatusCode;
title = "Validation Error";
messages.AddRange(validationExp.MessageProps);
break;
}
case CustomBaseException customBaseException:
{
statusCode = (int)customBaseException.StatusCode;
title = customBaseException.Title;

var responseExMessage = customBaseException.MessageFormat;
foreach (var (key, value) in customBaseException.MessageProps)
responseExMessage = responseExMessage.Replace(key, value);

messages.Add(responseExMessage);
break;
}
default:
messages.Add(UnexpectedErrorMessage);
break;
}

var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = string.Join(Environment.NewLine,messages.ToArray())
};

logger.LogError("Exception Detail {@Exception-Status} - {@Exception-Title} - {@Exception-Detail} - {@Context-Path}",
problemDetails.Status,
problemDetails.Title,
problemDetails.Detail,
context.Request.Path.Value);

var responseResult = JsonSerializer.Serialize(GenericResult<GenericResponse>.Exception(problemDetails));
await context.Response.WriteAsync(responseResult);
}
}
}

Burada middleware hata almadığı sürece await next(context); ile hayatını sürdürecektir. Hat alma durumunda catch’e (catch (Exception exception)) düştüğünde oluşan exception eğer bizim tanımladığımız istisnalardan biri ise burada istisnayı yakalayıp response model için işlemleri yapıyoruz.

-case ArgumentValidationException validationExp:
-case CustomBaseException customBaseException:
-case default tahmin edilemeyen exception types

switch (exception)
{
case ArgumentValidationException validationExp:
......
break;
case CustomBaseException customBaseException:
......
break;
default:
......
break;
}

Custom Exception Kullanımı

Bir Web Api projesi oluşturup endpoint ekleyelim.

Ben burada minimal api olarak tasarladım normal bir Controller olarakta tasarlayıp devam edebilirsiniz

var app = builder.Build();
app.RegistrationEndpoints();
 public static IEndpointRouteBuilder RegistrationEndpoints(this IEndpointRouteBuilder app)
{
app.MapControllers();
app.MapExceptionEndpoints();
return app;
}
public static class ErrorHandlingController
{
public static void MapExceptionEndpoints(this IEndpointRouteBuilder app)
{
var appGroup = app.MapGroup("api/exception-handling-with-result-pattern");
appGroup.MapPost("/already-exception", AlreadyAsync).WithName(nameof(AlreadyAsync));
appGroup.MapPost("/invalid-parameter-exception", InvalidParameterAsync).WithName(nameof(InvalidParameterAsync));
appGroup.MapPost("/create-operation-exception", CreateOperationAsync).WithName(nameof(CreateOperationAsync));
appGroup.MapPost("/not-found-exception", NotFoundAsync).WithName(nameof(NotFoundAsync));
appGroup.MapPost("/update-operation-exception", UpdateOperationAsync).WithName(nameof(UpdateOperationAsync));
appGroup.MapPost("/fail", FailAsync).WithName(nameof(FailAsync));
appGroup.MapPost("/success", SuccessAsync).WithName(nameof(SuccessAsync));
}

[HttpPost("error/already-exception")]
private static async Task<IResult> AlreadyAsync([FromBody] AlreadyRequest request,IMediator sender)
{
var response = await sender.Send(new AlreadyResponseCommand(request.UserId,request.Name));
return Results.Ok(response);
}

[HttpPost("error/invalid-parameter-exception")]
private static async Task<IResult> InvalidParameterAsync([FromBody] InvalidRequest request,IMediator sender)
{
var response = await sender.Send(new InvalidParameterResponseCommand(request.UserId,request.Name));
return Results.Ok(response);
}

...
}

Örnek istisna fırlatma:


using ExceptionHandlingWithResultPattern.Api.Data;
using ExceptionHandlingWithResultPattern.Api.Models.Responses;
using ExceptionHandlingWithResultPattern.Framework.Exceptions;
using ExceptionHandlingWithResultPattern.Framework.ResultPattern;
using MediatR;

namespace ExceptionHandlingWithResultPattern.Api.Features.ResultPatterns.Already;

public class AlreadyResponseCommandHandler:IRequestHandler<AlreadyResponseCommand,GenericResult<ResponseModelDto>>
{
public async Task<GenericResult<ResponseModelDto>> Handle(AlreadyResponseCommand command, CancellationToken cancellationToken)
{
var mockDataModels = MockDataModel.GetDatabaseExampleModels();
if (mockDataModels.Any(c => c.Name == command.Name))
throw new AlreadyExistException(nameof(AlreadyResponseCommand.Name),
command.Name,
nameof(MockDataModel));

return ...
}
}

Bu örnekleme ile diğer exception kullanımlarını inceleyebilirsiniz.

Response

Hata alma durumunda response incelendiğinde GenericResult model olarak belirlediğimiz model ile response düzenlenmiş görünüyor.

Gelin şimdi de success alan bir response model inceleyelim.

GenericResult<T> Kullanımı

Bu aşamada iş kuralı sonucu dönen response modeli GenericResult tipinde ayarlayıp dönüş sağlayacağız. Burada GenericResult tipinde dönüşü ister servis tarafında ister handler isterse controller tarafında ayarlayarak dönebiliriz bu tamamen kullanıma bağlı olarak değişkenlik gösterebilir.

Request
Response

Handler sınıfında ayarlanan GenericResult Success işlemi

public async class SuccessResponseCommandHandler:IRequestHandler<SuccessResponseCommand,GenericResult<ResponseModelDto>>
{
public async Task<GenericResult<ResponseModelDto>> Handle(SuccessResponseCommand command, CancellationToken cancellationToken)
{
return GenericResult<ResponseModelDto>.Success(new ResponseModelDto(command.UserId,command.Name));
}
}

GenericResult içerisinde Success, Exception ve Fail olarak tanımladığımız 3 metot bulunmaktadır. Exception almayan durumlar için Success kullandık. ExceptionHandlingMiddleware tarafında Exception metodunu kullandık peki bu Fail metodunu nerede kullanacağız.

Burası aslında biraz yoruma açık bir durum, ben örneklendirme olarak ekledim ancak bu durumda bir Exception olarak tasarlanabilir veya farklı bir şekilde düşünülebilir. Fail işlemi aslında bir info-warning gibi kullanmayı düşünerek oluşturdum.. İş kuralları içerisinde exceptiona denk gelmeyen, success olmayan, validation exception (fluent validation) olmadığı durumların dışında responsu anlamlı bir hale dönüştürmek için kullanılabilir. (Olmasada olur)

public class FailResponseQueryHandler:IRequestHandler<FailResponseQuery,GenericResult<ResponseModelDto>>
{
public async Task<GenericResult<ResponseModelDto>> Handle(FailResponseQuery request, CancellationToken cancellationToken)
{
var mockDataModels = MockDataModel.GetDatabaseExampleModels();

var mockItem = mockDataModels.FirstOrDefault(c => c.UserId.Equals(request.UserId));
if (mockItem is null)
throw new NotFoundException(nameof(MockDataModel), nameof(MockDataModel.UserId),request.UserId);

if (mockItem.Name != request.Name)
return GenericResult<ResponseModelDto>.Fail($"Girdiğiniz Name : {request.Name} alanı ile modele ait Name : {mockItem.Name} alanı eşleşmiyor.");

return GenericResult<ResponseModelDto>.Success(new ResponseModelDto{UserId = request.UserId, Name = request.Name});
}
}

ExceptionHandlingMiddleware içinde validationException tipinde tahmin edilebilir bir istisna olarak görebileceğimiz ArgumentValidationException olarak tanımlanan başka bir istisna tipi bulunmaktadır. Bunu kullanmak için FluentValidation ile request-command işlemlerinde validator kullanarak yapabiliriz.

ValidationBehavior tanımlayalım.

public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators) 
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var context = new ValidationContext<TRequest>(request);
var failures = validators
.Select(x => x.Validate(context))
.SelectMany(x => x.Errors)
.Where(x => x != null)
.ToList();

if (failures.Count == 0)
return next();

throw new ArgumentValidationException(
failures.Select(failure => failure.ErrorMessage)
.ToList());
}
}

Farklı bir endpoint yazalım ve validator sınıfını oluşturalım. UserId guid tipinde olduğu bir adet type ve bir adet te null check validasyonu ekleyelim.

public class ValidateErrorResponseCommandValidator: AbstractValidator<ValidateErrorResponseCommand>
{
public ValidateErrorResponseCommandValidator()
{
var nullOrEmptyMsg = ValidationConsts.NullOrEmpty_Validation;
var guidRegexMsg = ValidationConsts.GuidRegex_Validation;


RuleFor(x => x.UserId)
.Cascade(CascadeMode.Stop)
.NotEmpty()
.WithMessage(nullOrEmptyMsg.Replace(ValidationConsts.FieldName, nameof(ValidateErrorResponseCommand.UserId)));

RuleFor(x => x.UserId)
.Cascade(CascadeMode.Stop)
.Matches(ValidationConsts.GuidRegex)
.WithMessage(guidRegexMsg.Replace(ValidationConsts.FieldName, nameof(ValidateErrorResponseCommand.UserId)));

RuleFor(x => x.Name)
.Cascade(CascadeMode.Stop)
.NotEmpty()
.WithMessage(nullOrEmptyMsg.Replace(ValidationConsts.FieldName, nameof(ValidateErrorResponseCommand.Name)));

}
}
Request
Response

Kısaca bir bakış açısı olması için karalamış olduğum bu yazı ile alakalı kodlarını buradan erişebilirsiniz.

Farklı bir konu ile görüşmek dileğiyle. Umarım faydalı olmuştur.

--

--