DEV Community

nikosst
nikosst

Posted on

Result Pattern στο ASP.NET Core: Πώς να διαχειριζόμαστε επιτυχίες και αποτυχίες χωρίς να βασιζόμαστε στα Exceptions

Εισαγωγή

Ένα από τα σημαντικότερα ζητήματα στον σχεδιασμό μιας σύγχρονης εφαρμογής είναι ο τρόπος με τον οποίο διαχειριζόμαστε τις αποτυχίες.

Στην καθημερινότητα ενός .NET developer, αρκετές λειτουργίες μπορούν να αποτύχουν για απολύτως αναμενόμενους λόγους.

Για παράδειγμα:

Ένας χρήστης προσπαθεί να εγγραφεί με email που υπάρχει ήδη.

Ένα προϊόν δεν βρίσκεται στη βάση δεδομένων.

Μια παραγγελία δεν μπορεί να ολοκληρωθεί επειδή δεν υπάρχει διαθέσιμο απόθεμα.

Ένας χρήστης προσπαθεί να εκτελέσει μια ενέργεια για την οποία δεν έχει δικαιώματα.

Μια πληρωμή απορρίπτεται από τον πάροχο πληρωμών.

Σε όλες αυτές τις περιπτώσεις, η εφαρμογή δεν αντιμετωπίζει απαραίτητα κάποιο τεχνικό πρόβλημα. Αντίθετα, πρόκειται για αναμενόμενα αποτελέσματα της επιχειρησιακής λογικής.

Παρόλα αυτά, σε πολλές εφαρμογές χρησιμοποιούμε exceptions για να εκφράσουμε ακόμη και αυτές τις αναμενόμενες καταστάσεις.

Είναι όμως αυτή η καλύτερη επιλογή;

Εδώ έρχεται το Result Pattern.

Το Result Pattern είναι μια προσέγγιση σχεδιασμού που επιτρέπει σε μια μέθοδο να επιστρέφει ρητά είτε ένα επιτυχημένο αποτέλεσμα είτε μια αποτυχία, χωρίς να χρησιμοποιεί exceptions ως τον συνηθισμένο μηχανισμό ελέγχου ροής.

Στο άρθρο αυτό θα εξετάσουμε:

Ποιο πρόβλημα προσπαθεί να λύσει το Result Pattern.

Πώς λειτουργεί και ποιες αρχές κρύβονται πίσω του.

Πώς υλοποιούμε μια δική μας, strongly typed έκδοση σε C#.

Πώς το ενσωματώνουμε σε ASP.NET Core Web APIs.

Πώς συνδέεται με Clean Architecture, CQRS και Domain-Driven Design.

Πώς διαχειριζόμαστε validation, errors και HTTP status codes.

Πώς γράφουμε unit tests.

Πότε αξίζει να το χρησιμοποιούμε και πότε όχι.

Στόχος δεν είναι απλώς να δημιουργήσουμε μια κλάση Result, αλλά να κατανοήσουμε πώς αυτή η επιλογή επηρεάζει την αρχιτεκτονική και τη συντηρησιμότητα μιας εφαρμογής.

  1. Το πρόβλημα: Exceptions για αναμενόμενες αποτυχίες

Ας ξεκινήσουμε με ένα απλό παράδειγμα.

Έχουμε μια εφαρμογή στην οποία οι χρήστες μπορούν να δημιουργήσουν λογαριασμό.

Ένας βασικός κανόνας είναι ότι δεν επιτρέπονται δύο χρήστες με το ίδιο email.

Μια συνηθισμένη υλοποίηση θα μπορούσε να είναι η εξής:

public async Task RegisterAsync(
string email,
CancellationToken cancellationToken)
{
var existingUser = await _userRepository
.GetByEmailAsync(email, cancellationToken);

if (existingUser is not null)
{
    throw new Exception("Email already exists");
}

var user = new User(email);

await _userRepository.AddAsync(
    user,
    cancellationToken);

return user;
Enter fullscreen mode Exit fullscreen mode

}

Ο κώδικας είναι απλός και λειτουργικός.

Ωστόσο, κρύβει ορισμένα σημαντικά προβλήματα.

1.1 Η υπογραφή της μεθόδου δεν αποκαλύπτει όλες τις πιθανές εκβάσεις

Παρατηρούμε την υπογραφή:

Task RegisterAsync(...)

Από αυτήν καταλαβαίνουμε ότι η μέθοδος επιστρέφει έναν User.

Δεν γνωρίζουμε όμως ότι μπορεί να αποτύχει επειδή το email χρησιμοποιείται ήδη.

Για να το ανακαλύψουμε, πρέπει να διαβάσουμε την υλοποίηση ή την τεκμηρίωση.

Αυτό γίνεται ιδιαίτερα προβληματικό σε μεγάλα συστήματα, όπου μια μέθοδος μπορεί να καλείται από πολλαπλά σημεία.

1.2 Τα exceptions γίνονται μέρος της κανονικής ροής

Το γεγονός ότι ένα email υπάρχει ήδη δεν είναι κάτι απρόβλεπτο.

Είναι ένας επιχειρησιακός κανόνας που γνωρίζουμε εκ των προτέρων.

Επομένως, η αποτυχία της εγγραφής αποτελεί φυσιολογική πιθανή έκβαση.

Αν χρησιμοποιούμε exceptions για τέτοιες περιπτώσεις, καταλήγουμε να αντιμετωπίζουμε αναμενόμενα αποτελέσματα σαν εξαιρετικές καταστάσεις.

1.3 Ο κώδικας που καλεί τη μέθοδο γίνεται πιο περίπλοκος

Ας δούμε πώς μπορεί να χρησιμοποιηθεί η προηγούμενη μέθοδος μέσα σε έναν controller:

[HttpPost]
public async Task Register(
RegisterRequest request,
CancellationToken cancellationToken)
{
try
{
var user = await _userService.RegisterAsync(
request.Email,
cancellationToken);

    return Ok(user);
}
catch (Exception)
{
    return BadRequest();
}
Enter fullscreen mode Exit fullscreen mode

}

Η συγκεκριμένη υλοποίηση έχει ένα σοβαρό μειονέκτημα.

Όλα τα exceptions μετατρέπονται σε HTTP 400.

Αυτό σημαίνει ότι ακόμη και ένα database connection failure ή ένα απροσδόκητο NullReferenceException μπορεί να εμφανιστεί στον client ως λανθασμένο αίτημα.

Η εφαρμογή χάνει τη δυνατότητα να διαχωρίζει μια επιχειρησιακή αποτυχία από ένα τεχνικό σφάλμα.

1.4 Δεν έχουν όλες οι αποτυχίες την ίδια σημασία

Είναι σημαντικό να ξεχωρίσουμε δύο κατηγορίες:

Αναμενόμενες αποτυχίες

Το email υπάρχει ήδη.

Το προϊόν δεν βρέθηκε.

Το υπόλοιπο δεν επαρκεί.

Η παραγγελία βρίσκεται σε κατάσταση που δεν επιτρέπει ακύρωση.

Απροσδόκητες αποτυχίες

Η βάση δεδομένων δεν είναι διαθέσιμη.

Παρουσιάστηκε σφάλμα προγραμματισμού.

Ένα εξωτερικό dependency επιστρέφει μη αναμενόμενη απάντηση.

Παραβιάστηκε μια εσωτερική τεχνική υπόθεση.

Το Result Pattern είναι ιδιαίτερα χρήσιμο για την πρώτη κατηγορία.

Δεν σημαίνει ότι πρέπει να καταργήσουμε τα exceptions από την εφαρμογή μας.

Σημαίνει ότι πρέπει να χρησιμοποιούμε τον κατάλληλο μηχανισμό για κάθε κατηγορία αποτυχίας.

  1. Τι είναι το Result Pattern;

Το Result Pattern είναι ένα μοτίβο σχεδιασμού σύμφωνα με το οποίο μια λειτουργία επιστρέφει ένα αντικείμενο που αναπαριστά το αποτέλεσμά της.

Αυτό το αντικείμενο μπορεί να βρίσκεται σε μία από δύο καταστάσεις:

Success: Η λειτουργία ολοκληρώθηκε επιτυχώς.

Failure: Η λειτουργία δεν ολοκληρώθηκε και επιστρέφεται πληροφορία για τον λόγο αποτυχίας.

Μια τυπική χρήση θα μπορούσε να είναι:

Result result =
await userService.RegisterAsync(
email,
cancellationToken);

if (result.IsFailure)
{
Console.WriteLine(result.Error.Description);
return;
}

User user = result.Value;

Το σημαντικό είναι ότι η πιθανότητα αποτυχίας εμφανίζεται πλέον στην υπογραφή:

Task> RegisterAsync(...)

Ο caller γνωρίζει ότι δεν λαμβάνει απλώς έναν User, αλλά ένα αποτέλεσμα που πρέπει να αξιολογήσει.

Αυτή η ιδέα συνδέεται με τις έννοιες Either και Result που συναντάμε στον functional programming κόσμο.

Στη C#, όπου δεν υπάρχει ενσωματωμένος γενικός discriminated union τύπος με πλήρη exhaustiveness checking, μπορούμε να προσεγγίσουμε αυτή τη συμπεριφορά με δικούς μας τύπους.

Μια σημαντική διευκρίνιση

Το Result Pattern δεν εξαναγκάζει από μόνο του τον caller να ελέγξει την αποτυχία.

Η C# εξακολουθεί να επιτρέπει σε κάποιον να αγνοήσει ένα επιστρεφόμενο αποτέλεσμα.

Επιπλέον, ένα χειροποίητο Result δεν παρέχει αυτόματα compile-time exhaustiveness checking.

Το βασικό πλεονέκτημα είναι ότι η αποτυχία γίνεται ρητό μέρος του συμβολαίου της μεθόδου, αντί να παραμένει κρυμμένη σε κάποιο exception path.

  1. Σχεδιάζοντας το Error Model

Πριν δημιουργήσουμε το Result, πρέπει να αποφασίσουμε πώς θα αναπαριστούμε τα errors.

Μια πρώτη προσέγγιση θα ήταν να επιστρέφουμε απλώς ένα string:

return Result.Failure(
"Email already exists");

Αυτό λειτουργεί, αλλά έχει περιορισμούς.

Ένα string δεν περιγράφει με δομημένο τρόπο το είδος του σφάλματος.

Επίσης, αν κάποιος αλλάξει το μήνυμα, μπορεί να επηρεάσει κώδικα που βασίζεται στο συγκεκριμένο κείμενο.

Για παράδειγμα, δεν θα θέλαμε να γράφουμε:

if (result.Error == "Email already exists")
{
// Handle duplicate email
}

Πρόκειται για εύθραυστο συμβόλαιο.

Αντίθετα, θα δημιουργήσουμε ένα strongly typed error model.

3.1 ErrorType

Αρχικά ορίζουμε τις βασικές κατηγορίες σφαλμάτων.

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

Κάθε κατηγορία εκφράζει μια διαφορετική σημασιολογία.

Για παράδειγμα, το NotFound σημαίνει ότι ο πόρος που αναζητούμε δεν υπάρχει, ενώ το Conflict σημαίνει ότι η λειτουργία συγκρούεται με την τρέχουσα κατάσταση του συστήματος.

3.2 Error

Στη συνέχεια δημιουργούμε τον τύπο Error.

public sealed record Error(
string Code,
string Description,
ErrorType Type)
{
public static Error Failure(
string code,
string description) =>
new(code, description, ErrorType.Failure);

public static Error Validation(
    string code,
    string description) =>
    new(code, description, ErrorType.Validation);

public static Error NotFound(
    string code,
    string description) =>
    new(code, description, ErrorType.NotFound);

public static Error Conflict(
    string code,
    string description) =>
    new(code, description, ErrorType.Conflict);

public static Error Forbidden(
    string code,
    string description) =>
    new(code, description, ErrorType.Forbidden);

public static Error Unauthorized(
    string code,
    string description) =>
    new(code, description, ErrorType.Unauthorized);
Enter fullscreen mode Exit fullscreen mode

}

Χρησιμοποιούμε record επειδή θέλουμε value-based equality.

Δύο errors με τα ίδια δεδομένα θεωρούνται ισοδύναμα.

Επίσης, το Error είναι immutable, γεγονός που περιορίζει την πιθανότητα ανεπιθύμητων αλλαγών μετά τη δημιουργία του.

3.3 Γιατί χρειαζόμαστε Code και Description;

Ας εξετάσουμε το ακόλουθο error:

public static readonly Error EmailAlreadyExists =
Error.Conflict(
"Users.EmailAlreadyExists",
"A user with this email already exists.");

Το Code είναι ένα σταθερό αναγνωριστικό.

Μπορεί να χρησιμοποιηθεί για:

Error handling.

Client-side translations.

Monitoring.

Structured logging.

Automated tests.

Το Description είναι το μήνυμα που περιγράφει το πρόβλημα.

Ο διαχωρισμός είναι σημαντικός επειδή το κείμενο ενός μηνύματος μπορεί να αλλάξει, ενώ το error code πρέπει ιδανικά να παραμένει σταθερό.

Σε ένα production API, τα codes πρέπει να θεωρούνται μέρος του συμβολαίου προς τους clients.

  1. Υλοποιώντας το Result Pattern σε C#

Ας περάσουμε στην κεντρική υλοποίηση.

Θα δημιουργήσουμε δύο τύπους:

Result για λειτουργίες που δεν επιστρέφουν τιμή.

Result για λειτουργίες που επιστρέφουν δεδομένα.

Η διάκριση είναι χρήσιμη.

Μια λειτουργία διαγραφής, για παράδειγμα, μπορεί να χρειάζεται μόνο να μας ενημερώσει αν ολοκληρώθηκε.

Αντίθετα, μια λειτουργία δημιουργίας χρήστη χρειάζεται να επιστρέψει και το αναγνωριστικό του νέου χρήστη.

4.1 Η βασική κλάση Result

public class Result
{
public bool IsSuccess { get; }

public bool IsFailure => !IsSuccess;

public Error? Error { get; }

protected Result(bool isSuccess, Error? error)
{
    if (isSuccess && error is not null)
    {
        throw new ArgumentException(
            "A successful result cannot contain an error.");
    }

    if (!isSuccess && error is null)
    {
        throw new ArgumentException(
            "A failed result must contain an error.");
    }

    IsSuccess = isSuccess;
    Error = error;
}

public static Result Success() =>
    new(true, null);

public static Result Failure(Error error) =>
    new(false, error);

public static Result<T> Success<T>(T value) =>
    Result<T>.Success(value);

public static Result<T> Failure<T>(Error error) =>
    Result<T>.Failure(error);
Enter fullscreen mode Exit fullscreen mode

}

Η σημαντικότερη λεπτομέρεια βρίσκεται στον constructor.

Δεν επιτρέπουμε να δημιουργηθεί ένα επιτυχημένο αποτέλεσμα που περιέχει error.

Ούτε επιτρέπουμε να δημιουργηθεί αποτυχημένο αποτέλεσμα χωρίς error.

Αυτές οι συνθήκες είναι τα invariants του τύπου μας.

Με άλλα λόγια, ορίζουμε ποιες καταστάσεις θεωρούνται έγκυρες και αποτρέπουμε τη δημιουργία μη έγκυρων αντικειμένων.

4.2 Το generic Result

public sealed class Result : Result
{
private readonly T? _value;

private Result(
    bool isSuccess,
    T? value,
    Error? error)
    : base(isSuccess, error)
{
    _value = value;
}

public T Value =>
    IsSuccess
        ? _value!
        : throw new InvalidOperationException(
            "Cannot access the value of a failed result.");

public static Result<T> Success(T value) =>
    new(true, value, null);

public static new Result<T> Failure(Error error) =>
    new(false, default, error);
Enter fullscreen mode Exit fullscreen mode

}

Εδώ εισάγουμε μια σημαντική αρχή.

Το Value είναι διαθέσιμο μόνο όταν το αποτέλεσμα είναι επιτυχημένο.

Αν προσπαθήσουμε να το διαβάσουμε σε αποτυχημένο αποτέλεσμα, δημιουργείται InvalidOperationException.

Μπορεί αυτό να φαίνεται αντιφατικό;

Αφού προσπαθούμε να αποφύγουμε τα exceptions, γιατί χρησιμοποιούμε ένα μέσα στο Result;

Η απάντηση βρίσκεται στη διάκριση ανάμεσα στις αναμενόμενες επιχειρησιακές αποτυχίες και στη λανθασμένη χρήση ενός API.

Το να μην υπάρχει ένας χρήστης στη βάση δεδομένων είναι αναμενόμενο.

Το να προσπαθήσει ένας developer να διαβάσει το Value από ένα αποτυχημένο Result παραβιάζει το συμβόλαιο του τύπου.

Το δεύτερο αποτελεί programming error.

Senior-level σημείωση: Η παραπάνω υλοποίηση είναι σκόπιμα μικρή για εκπαιδευτικούς λόγους. Το T? και ο τελεστής ! δεν επιβάλλουν πραγματικό non-null invariant για reference types. Αν το συμβόλαιό μας απαγορεύει το Success(null), πρέπει να προσθέσουμε ρητό έλεγχο ή να επιλέξουμε διαφορετικό μοντέλο nullability. Επίσης, η ιδιότητα Error είναι nullable, οπότε ο compiler δεν μπορεί να συμπεράνει ότι μετά από IsFailure είναι μη-null.

  1. Ορίζοντας Domain Errors

Μια καλή πρακτική είναι να ορίζουμε τα errors κοντά στο domain στο οποίο ανήκουν.

Για παράδειγμα:

public static class UserErrors
{
public static readonly Error EmailAlreadyExists =
Error.Conflict(
"Users.EmailAlreadyExists",
"A user with this email already exists.");

public static Error NotFound(Guid userId) =>
    Error.NotFound(
        "Users.NotFound",
        $"User with id '{userId}' was not found.");

public static readonly Error InvalidEmail =
    Error.Validation(
        "Users.InvalidEmail",
        "The provided email is invalid.");
Enter fullscreen mode Exit fullscreen mode

}

Αυτό προσφέρει δύο σημαντικά οφέλη.

Πρώτον, τα errors είναι επαναχρησιμοποιήσιμα.

Δεύτερον, οι υπηρεσίες δεν χρειάζεται να γνωρίζουν πώς κατασκευάζεται κάθε error.

Μπορούν απλώς να επιστρέφουν ένα γνωστό, καλά ορισμένο domain error.

Παρατηρούμε επίσης ότι το NotFound είναι μέθοδος και όχι στατικό πεδίο, επειδή το description περιλαμβάνει δυναμική πληροφορία.

Σε πραγματικές εφαρμογές πρέπει να προσέχουμε ώστε τέτοια μηνύματα να μην αποκαλύπτουν προσωπικά δεδομένα ή εσωτερικές πληροφορίες.

  1. Πρακτικό παράδειγμα: User Registration

Ας εφαρμόσουμε τώρα το Result Pattern σε ένα πιο ρεαλιστικό σενάριο.

Θέλουμε να δημιουργήσουμε μια λειτουργία εγγραφής χρήστη.

Η διαδικασία περιλαμβάνει:

Έλεγχο εγκυρότητας του email.

Έλεγχο αν υπάρχει ήδη χρήστης με το ίδιο email.

Δημιουργία του χρήστη.

Αποθήκευση στη βάση δεδομένων.

Επιστροφή του αναγνωριστικού.

6.1 Το Request

public sealed record RegisterUserRequest(
string Email);

6.2 Το Response

public sealed record RegisterUserResponse(
Guid UserId,
string Email);

6.3 Το Repository Contract

public interface IUserRepository
{
Task ExistsByEmailAsync(
string email,
CancellationToken cancellationToken);

Task AddAsync(
    User user,
    CancellationToken cancellationToken);
Enter fullscreen mode Exit fullscreen mode

}

Για το παράδειγμα υποθέτουμε ότι το repository αποθηκεύει τον χρήστη κατά την κλήση AddAsync.

Σε μια εφαρμογή που χρησιμοποιεί Unit of Work, η αποθήκευση μπορεί να πραγματοποιείται αργότερα μέσω SaveChangesAsync.

6.4 Το Domain Entity

public sealed class User
{
public Guid Id { get; private set; }

public string Email { get; private set; }

private User(Guid id, string email)
{
    Id = id;
    Email = email;
}

public static User Create(string email)
{
    return new User(
        Guid.NewGuid(),
        email);
}
Enter fullscreen mode Exit fullscreen mode

}

Για να παραμείνει το παράδειγμα εστιασμένο στο Result Pattern, κρατάμε την οντότητα απλή.

Σε ένα πιο αυστηρό domain model θα μπορούσαμε να χρησιμοποιήσουμε Email value object, ώστε να επιβάλλουμε την εγκυρότητα και την κανονικοποίηση της διεύθυνσης στο ίδιο το domain.

6.5 Το Application Service

public sealed class UserService
{
private readonly IUserRepository _userRepository;

public UserService(IUserRepository userRepository)
{
    _userRepository = userRepository;
}

public async Task<Result<RegisterUserResponse>> RegisterAsync(
    RegisterUserRequest request,
    CancellationToken cancellationToken)
{
    if (string.IsNullOrWhiteSpace(request.Email))
    {
        return Result.Failure<RegisterUserResponse>(
            UserErrors.InvalidEmail);
    }

    var normalizedEmail = request.Email
        .Trim()
        .ToLowerInvariant();

    var emailExists = await _userRepository
        .ExistsByEmailAsync(
            normalizedEmail,
            cancellationToken);

    if (emailExists)
    {
        return Result.Failure<RegisterUserResponse>(
            UserErrors.EmailAlreadyExists);
    }

    var user = User.Create(normalizedEmail);

    await _userRepository.AddAsync(
        user,
        cancellationToken);

    var response = new RegisterUserResponse(
        user.Id,
        user.Email);

    return Result.Success(response);
}
Enter fullscreen mode Exit fullscreen mode

}

Ας εξετάσουμε προσεκτικά τι συμβαίνει.

Πρώτο βήμα: Validation

if (string.IsNullOrWhiteSpace(request.Email))
{
return Result.Failure(
UserErrors.InvalidEmail);
}

Αν το email είναι κενό, η μέθοδος επιστρέφει ένα αποτυχημένο Result.

Δεν δημιουργείται exception.

Ο caller μπορεί να ελέγξει το error και να αποφασίσει πώς θα αντιδράσει.

Ο έλεγχος αυτός δεν αποτελεί πλήρη email validation. Είναι απλώς ένας αρχικός έλεγχος για τις ανάγκες του παραδείγματος.

Δεύτερο βήμα: Business Rule

if (emailExists)
{
return Result.Failure(
UserErrors.EmailAlreadyExists);
}

Εδώ εφαρμόζουμε τον κανόνα ότι δεν επιτρέπονται διπλές εγγραφές με το ίδιο email.

Η αποτυχία είναι αναμενόμενη.

Επομένως, επιστρέφουμε Result αντί να κάνουμε throw.

Τρίτο βήμα: Success

return Result.Success(response);

Η μέθοδος ολοκληρώνεται με ένα επιτυχημένο Result που περιέχει το response.

Ο caller γνωρίζει ότι το Value είναι διαθέσιμο όταν το IsSuccess είναι true.

6.6 Ένα κρίσιμο production ζήτημα: Concurrency

Η προηγούμενη υλοποίηση περιέχει έναν κλασικό κίνδυνο.

Φανταστείτε δύο ταυτόχρονα requests με το ίδιο email.

Και τα δύο μπορούν να εκτελέσουν τον έλεγχο:

ExistsByEmailAsync(email)

Και τα δύο μπορεί να λάβουν false.

Στη συνέχεια, και τα δύο προσπαθούν να αποθηκεύσουν τον χρήστη.

Αυτό είναι ένα race condition.

Το Result Pattern δεν λύνει το συγκεκριμένο πρόβλημα.

Η πραγματική προστασία πρέπει να βρίσκεται και στη βάση δεδομένων, μέσω unique constraint ή unique index στο κανονικοποιημένο email.

Όταν προκύψει η συγκεκριμένη παραβίαση, το persistence layer μπορεί να την αναγνωρίσει και να τη μετατρέψει σε ένα γνωστό EmailAlreadyExists Result, εφόσον αυτό αποτελεί μέρος του συμβολαίου του.

Δεν πρέπει όμως να μετατρέπουμε κάθε DbUpdateException σε conflict.

Ένα DbUpdateException μπορεί να προκύψει για πολλούς διαφορετικούς λόγους.

Χρειάζεται provider-specific αναγνώριση της συγκεκριμένης παραβίασης και προσεκτικός χειρισμός.

Αυτή είναι μια σημαντική διάκριση ανάμεσα σε ένα εκπαιδευτικό παράδειγμα και μια production-ready υλοποίηση.

  1. Ενσωμάτωση σε ASP.NET Core Web API

Μέχρι τώρα το Application Service επιστρέφει ένα Result.

Το επόμενο ερώτημα είναι πώς μετατρέπουμε αυτό το αποτέλεσμα σε HTTP response.

Για παράδειγμα:

Result

HTTP Status

Success

200 OK ή 201 Created

Validation

400 Bad Request

NotFound

404 Not Found

Conflict

409 Conflict

Unauthorized

401 Unauthorized

Forbidden

403 Forbidden

Ο πίνακας είναι μια ενδεικτική πολιτική mapping και όχι ένας απόλυτος κανόνας.

Για παράδειγμα, σε ένα API που χρησιμοποιεί model validation μπορεί να επιλεγεί HTTP 422 για συγκεκριμένες semantic validation failures.

Επίσης, η πραγματική authentication και authorization συμπεριφορά πρέπει να παραμένει συμβατή με το security pipeline του ASP.NET Core.

7.1 Γιατί δεν πρέπει να επιστρέφουμε IActionResult από το Application Layer;

Θα μπορούσαμε να γράψουμε:

public async Task RegisterAsync(...)

Ωστόσο, αυτό θα δημιουργούσε εξάρτηση του application layer από το ASP.NET Core.

Η επιχειρησιακή λογική θα γνώριζε πλέον για HTTP status codes και MVC abstractions.

Σε μια Clean Architecture προσέγγιση θέλουμε να αποφύγουμε αυτή την εξάρτηση.

Το application layer πρέπει να εκφράζει το αποτέλεσμα της λειτουργίας.

Το presentation layer πρέπει να αποφασίζει πώς θα το παρουσιάσει στον εξωτερικό κόσμο.

7.2 Mapping σε ProblemDetails

Το ASP.NET Core υποστηρίζει το ProblemDetails, ένα καθιερωμένο format για την αναπαράσταση HTTP API errors.

Μπορούμε να δημιουργήσουμε έναν κεντρικό mapper:

using Microsoft.AspNetCore.Mvc;

public static class ResultExtensions
{
public static IActionResult ToActionResult(
this Result result,
ControllerBase controller)
{
if (result.IsSuccess)
{
return controller.Ok(result.Value);
}

    var error = result.Error!;

    var statusCode = error.Type switch
    {
        ErrorType.Validation =>
            StatusCodes.Status400BadRequest,

        ErrorType.NotFound =>
            StatusCodes.Status404NotFound,

        ErrorType.Conflict =>
            StatusCodes.Status409Conflict,

        ErrorType.Unauthorized =>
            StatusCodes.Status401Unauthorized,

        ErrorType.Forbidden =>
            StatusCodes.Status403Forbidden,

        _ =>
            StatusCodes.Status400BadRequest
    };

    var problem = new ProblemDetails
    {
        Status = statusCode,
        Title = error.Code,
        Detail = error.Description
    };

    return new ObjectResult(problem)
    {
        StatusCode = statusCode,
        ContentTypes =
        {
            "application/problem+json"
        }
    };
}
Enter fullscreen mode Exit fullscreen mode

}

Το ! χρησιμοποιείται επειδή ο compiler δεν μπορεί να συμπεράνει από το IsFailure ότι το Error είναι μη-null.

Η εγγύηση προκύπτει από τον constructor του Result.

Σε μεγαλύτερη υλοποίηση θα μπορούσαμε να χρησιμοποιήσουμε TryGetError, pattern matching ή ένα διαφορετικό union-like API ώστε να αποφύγουμε αυτόν τον τελεστή.

7.3 Ο Controller

Ο controller γίνεται πλέον αρκετά απλός.

[ApiController]
[Route("api/users")]
public sealed class UsersController : ControllerBase
{
private readonly UserService _userService;

public UsersController(UserService userService)
{
    _userService = userService;
}

[HttpPost]
public async Task<IActionResult> Register(
    RegisterUserRequest request,
    CancellationToken cancellationToken)
{
    var result = await _userService.RegisterAsync(
        request,
        cancellationToken);

    if (result.IsFailure)
    {
        return result.ToActionResult(this);
    }

    return Created(
        $"/api/users/{result.Value.UserId}",
        result.Value);
}
Enter fullscreen mode Exit fullscreen mode

}

Παρατηρούμε ότι ο controller δεν γνωρίζει τις λεπτομέρειες των business rules.

Δεν χρειάζεται να ξέρει πώς γίνεται ο έλεγχος του email.

Ούτε χρειάζεται να κάνει catch ένα exception για κάθε αναμενόμενη αποτυχία.

Απλώς μετατρέπει το αποτέλεσμα της εφαρμογής σε HTTP response.

Για τη συγκεκριμένη λειτουργία δημιουργίας χρησιμοποιούμε 201 Created.

Αν το API διαθέτει endpoint ανάκτησης χρήστη, μπορούμε να χρησιμοποιήσουμε και CreatedAtAction ή CreatedAtRoute, ώστε να παράγουμε το resource location με βάση το routing configuration.

7.4 Παράδειγμα HTTP Response

Αν το email υπάρχει ήδη, το API μπορεί να επιστρέψει:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
"type": "about:blank",
"title": "Users.EmailAlreadyExists",
"status": 409,
"detail": "A user with this email already exists."
}

Αυτό το response είναι προβλέψιμο και μπορεί να καταναλωθεί από διαφορετικούς clients.

Το frontend μπορεί να βασίζεται στο error code αντί να συγκρίνει μηνύματα.

Για παράδειγμα:

if (problem.title === "Users.EmailAlreadyExists") {
showEmailAlreadyExistsMessage();
}

Σε μεγαλύτερα APIs θα ήταν προτιμότερο το error code να βρίσκεται σε ξεχωριστό extension property, όπως code, και το title να χρησιμοποιείται για μια σύντομη, ανθρώπινα αναγνώσιμη περιγραφή.

  1. Result Pattern και Clean Architecture

Το Result Pattern ταιριάζει ιδιαίτερα καλά σε εφαρμογές που ακολουθούν Clean Architecture.

Μια τυπική δομή θα μπορούσε να είναι:

src/
├── MyApp.Domain/
│ ├── Entities/
│ └── Errors/
│
├── MyApp.Application/
│ ├── Abstractions/
│ ├── Users/
│ └── Common/
│ └── Results/
│
├── MyApp.Infrastructure/
│ ├── Persistence/
│ └── Repositories/
│
└── MyApp.Api/
├── Controllers/
└── Extensions/

Σε αυτή τη δομή, το Result βρίσκεται στο Application layer ή σε ένα μικρό κοινό abstraction assembly, εφόσον υπάρχει πραγματική ανάγκη να χρησιμοποιείται από περισσότερα layers.

Τα domain errors μπορούν να ορίζονται στο Domain layer όταν αντιπροσωπεύουν καθαρά domain concepts.

Ο HTTP mapper βρίσκεται στο API layer.

Το βασικό πλεονέκτημα είναι ότι διαχωρίζουμε τις ευθύνες.

Domain: Ποιοι επιχειρησιακοί κανόνες ισχύουν;

Application: Πώς εκτελείται ένα use case και ποια αποτελέσματα μπορεί να επιστρέψει;

Infrastructure: Πώς επικοινωνούμε με τη βάση δεδομένων και τα εξωτερικά συστήματα;

Presentation: Πώς μετατρέπουμε τα αποτελέσματα σε HTTP responses;

Το Result Pattern δεν επιβάλλει από μόνο του Clean Architecture, αλλά μπορεί να υποστηρίξει αποτελεσματικά τον διαχωρισμό των layers.

  1. Result Pattern και CQRS

Αν χρησιμοποιούμε CQRS, μπορούμε να εφαρμόσουμε την ίδια προσέγγιση σε commands και queries.

Για παράδειγμα, ένα command για δημιουργία χρήστη:

public sealed record CreateUserCommand(
string Email);

Και ένας handler:

public sealed class CreateUserHandler
{
private readonly IUserRepository _repository;

public CreateUserHandler(IUserRepository repository)
{
    _repository = repository;
}

public async Task<Result<Guid>> Handle(
    CreateUserCommand command,
    CancellationToken cancellationToken)
{
    var exists = await _repository.ExistsByEmailAsync(
        command.Email,
        cancellationToken);

    if (exists)
    {
        return Result.Failure<Guid>(
            UserErrors.EmailAlreadyExists);
    }

    var user = User.Create(command.Email);

    await _repository.AddAsync(
        user,
        cancellationToken);

    return Result.Success(user.Id);
}
Enter fullscreen mode Exit fullscreen mode

}

Ο handler επιστρέφει ένα Result.

Δεν επιστρέφει IActionResult.

Δεν γνωρίζει HTTP status codes.

Επομένως, ο ίδιος handler μπορεί να χρησιμοποιηθεί από ένα Web API, ένα background job ή έναν message consumer.

Το Result Pattern κάνει το συμβόλαιο του use case ανεξάρτητο από το transport mechanism.

  1. Συνδυάζοντας πολλαπλά Results

Σε πιο σύνθετα workflows, μπορεί να χρειαστεί να εκτελέσουμε διαδοχικές λειτουργίες.

Για παράδειγμα:

Εντοπισμός χρήστη.

Έλεγχος δικαιωμάτων.

Δημιουργία παραγγελίας.

Αποθήκευση παραγγελίας.

Κάθε βήμα μπορεί να επιστρέφει Result.

Μια απλή υλοποίηση θα μπορούσε να είναι:

public async Task> CreateOrderAsync(
Guid userId,
CancellationToken cancellationToken)
{
var userResult = await GetUserAsync(
userId,
cancellationToken);

if (userResult.IsFailure)
{
    return Result.Failure<Guid>(
        userResult.Error!);
}

var permissionResult = await CheckPermissionAsync(
    userResult.Value,
    cancellationToken);

if (permissionResult.IsFailure)
{
    return Result.Failure<Guid>(
        permissionResult.Error!);
}

var order = new Order(userId);

await _orderRepository.AddAsync(
    order,
    cancellationToken);

return Result.Success(order.Id);
Enter fullscreen mode Exit fullscreen mode

}

Ο κώδικας είναι σαφής, αλλά σε μεγάλα workflows μπορεί να εμφανιστεί αρκετό επαναλαμβανόμενο error handling.

Εδώ μπορούν να βοηθήσουν functional composition τεχνικές όπως Bind και Map.

10.1 Map

Το Map μετασχηματίζει την τιμή ενός επιτυχημένου Result.

Αν το Result είναι αποτυχημένο, διατηρεί το error.

public static Result Map(
this Result result,
Func mapper)
{
if (result.IsFailure)
{
return Result.Failure(
result.Error!);
}

return Result.Success(
    mapper(result.Value));
Enter fullscreen mode Exit fullscreen mode

}

Παράδειγμα:

Result userResult = GetUser();

Result emailResult = userResult.Map(
user => user.Email);

Αν ο χρήστης βρέθηκε, το αποτέλεσμα περιέχει το email.

Αν ο χρήστης δεν βρέθηκε, το αποτέλεσμα διατηρεί το αρχικό error.

10.2 Bind

Το Bind χρησιμοποιείται όταν η επόμενη λειτουργία επιστρέφει και αυτή Result.

public static Result Bind(
this Result result,
Func> binder)
{
if (result.IsFailure)
{
return Result.Failure(
result.Error!);
}

return binder(result.Value);
Enter fullscreen mode Exit fullscreen mode

}

Παράδειγμα:

Result userResult = GetUser();

Result orderResult = userResult.Bind(
user => CreateOrder(user));

Αν το πρώτο Result αποτύχει, η δεύτερη λειτουργία δεν εκτελείται.

Αν το πρώτο Result επιτύχει, η τιμή του περνά στη δεύτερη λειτουργία.

Αυτή η συμπεριφορά επιτρέπει να δημιουργούμε αλυσίδες λειτουργιών χωρίς να επαναλαμβάνουμε συνεχώς τον ίδιο έλεγχο.

Ωστόσο, χρειάζεται προσοχή.

Η υπερβολική χρήση functional abstractions μπορεί να κάνει έναν απλό επιχειρησιακό αλγόριθμο δυσκολότερο στην ανάγνωση.

Η αναγνωσιμότητα πρέπει να παραμένει σημαντικότερη από την προσπάθεια να εξαφανίσουμε κάθε if.

  1. Validation: Fail Fast ή Error Aggregation;

Μέχρι τώρα κάθε Result περιέχει ένα μόνο error.

Αυτό είναι αρκετό για πολλά business rules.

Ωστόσο, υπάρχουν περιπτώσεις όπου θέλουμε να επιστρέψουμε πολλαπλά validation errors.

Για παράδειγμα, σε μια φόρμα εγγραφής μπορεί να υπάρχουν ταυτόχρονα τα εξής προβλήματα:

Το email δεν είναι έγκυρο.

Ο κωδικός είναι πολύ μικρός.

Το όνομα είναι υποχρεωτικό.

Αν επιστρέψουμε μόνο το πρώτο error, ο χρήστης θα χρειαστεί να διορθώνει ένα πρόβλημα κάθε φορά.

Για validation scenarios μπορούμε να επιλέξουμε ένα πιο σύνθετο αποτέλεσμα:

public sealed record ValidationError(
string PropertyName,
string Code,
string Message);

Και να χρησιμοποιήσουμε ένα collection:

public sealed record ValidationFailure(
IReadOnlyList Errors);

Ένα τέτοιο μοντέλο μπορεί να συνδυαστεί με ValidationProblemDetails, ώστε το API να επιστρέφει σφάλματα ανά πεδίο.

Το σημαντικό είναι να διαχωρίσουμε δύο διαφορετικές στρατηγικές.

Fail Fast: Σταματάμε στην πρώτη αποτυχία. Είναι κατάλληλο όταν η συνέχεια της διαδικασίας δεν έχει νόημα χωρίς την επιτυχία του προηγούμενου βήματος.

Error Aggregation: Συγκεντρώνουμε ανεξάρτητα validation errors. Είναι κατάλληλο όταν θέλουμε να ενημερώσουμε τον χρήστη για όλα τα προβλήματα του request.

Δεν χρειάζεται να χρησιμοποιούμε την ίδια στρατηγική σε κάθε σημείο της εφαρμογής.

  1. Πότε χρησιμοποιούμε Exceptions αντί για Result;

Ένα από τα πιο συχνά λάθη είναι η προσπάθεια να μετατρέψουμε κάθε exception σε Result.

Αυτό δεν είναι απαραίτητα σωστό.

Ας εξετάσουμε μερικές περιπτώσεις.

Περίπτωση

Προτεινόμενος χειρισμός

Email ήδη καταχωρημένο

Result

Προϊόν δεν βρέθηκε

Result

Ανεπαρκές υπόλοιπο

Result

Απαγορευμένη αλλαγή κατάστασης

Result

Database connection failure

Exception

Απρόβλεπτο programming bug

Exception

CancellationToken cancellation

Συνήθως propagation της cancellation

Αναμενόμενη απόρριψη πληρωμής

Result

Απρόβλεπτο failure του payment provider

Exception ή ρητά μοντελοποιημένη αποτυχία, ανάλογα με το συμβόλαιο

Η τελευταία περίπτωση είναι ιδιαίτερα ενδιαφέρουσα.

Μια εξωτερική υπηρεσία μπορεί να αποτύχει για διαφορετικούς λόγους.

Μια απόρριψη πληρωμής λόγω ανεπαρκών χρημάτων είναι επιχειρησιακό αποτέλεσμα.

Αντίθετα, ένα network timeout μπορεί να αποτελεί προσωρινό τεχνικό πρόβλημα.

Σε ένα σύστημα με υψηλές απαιτήσεις αξιοπιστίας, ορισμένα infrastructure failures μπορούν επίσης να μοντελοποιηθούν ως Result, ειδικά όταν αποτελούν αναμενόμενο και ανακτήσιμο μέρος του συμβολαίου.

Το κριτήριο δεν είναι απλώς αν το πρόβλημα είναι τεχνικό ή επιχειρησιακό.

Το βασικό ερώτημα είναι:

Πρόκειται για μια αναμενόμενη κατάσταση που ο caller μπορεί και οφείλει να χειριστεί ως μέρος της κανονικής ροής;

Αν ναι, το Result μπορεί να είναι κατάλληλο.

Αν πρόκειται για παραβίαση εσωτερικής υπόθεσης ή μη αναμενόμενο failure, ένα exception συνήθως είναι πιο σωστό.

  1. Global Exception Handling και Result Pattern

Σε μια σωστά σχεδιασμένη εφαρμογή, το Result Pattern και το global exception handling λειτουργούν συμπληρωματικά.

Το Result Pattern χειρίζεται τις αναμενόμενες αποτυχίες.

Το global exception handling αναλαμβάνει τις απροσδόκητες αποτυχίες.

Στο ASP.NET Core μπορούμε να χρησιμοποιήσουμε το IExceptionHandler.

using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

public sealed class GlobalExceptionHandler
: IExceptionHandler
{
private readonly ILogger _logger;

public GlobalExceptionHandler(
    ILogger<GlobalExceptionHandler> logger)
{
    _logger = logger;
}

public async ValueTask<bool> TryHandleAsync(
    HttpContext httpContext,
    Exception exception,
    CancellationToken cancellationToken)
{
    _logger.LogError(
        exception,
        "An unexpected error occurred.");

    var problem = new ProblemDetails
    {
        Status = StatusCodes.Status500InternalServerError,
        Title = "Internal Server Error",
        Detail = "An unexpected error occurred."
    };

    httpContext.Response.StatusCode =
        StatusCodes.Status500InternalServerError;

    await httpContext.Response.WriteAsJsonAsync(
        problem,
        options: null,
        contentType: "application/problem+json",
        cancellationToken: cancellationToken);

    return true;
}
Enter fullscreen mode Exit fullscreen mode

}

Στο Program.cs:

builder.Services.AddProblemDetails();

builder.Services.AddExceptionHandler<
GlobalExceptionHandler>();

var app = builder.Build();

app.UseExceptionHandler();

app.MapControllers();

app.Run();

Η συγκεκριμένη υλοποίηση είναι απλοποιημένη.

Σε production περιβάλλον θα πρέπει να διατηρούμε τη σωστή συμπεριφορά για cancellations, να προσέχουμε αν έχει ήδη ξεκινήσει η HTTP response και να μην αποκαλύπτουμε εσωτερικές πληροφορίες στους clients.

Επίσης, για τα unexpected failures συνήθως θέλουμε structured logging και correlation identifiers.

Το βασικό αρχιτεκτονικό συμπέρασμα παραμένει το ίδιο:

Δεν χρειάζεται να επιλέξουμε ανάμεσα σε Result Pattern και exception handling.

Χρειαζόμαστε και τα δύο, αλλά για διαφορετικές ευθύνες.

  1. Unit Testing με Result Pattern

Ένα σημαντικό πλεονέκτημα του Result Pattern είναι ότι οι αναμενόμενες αποτυχίες μπορούν να ελεγχθούν χωρίς να βασιζόμαστε σε exception assertions.

Ας δούμε ένα παράδειγμα με xUnit και Moq.

[Fact]
public async Task RegisterAsync_ShouldReturnConflict_WhenEmailExists()
{
// Arrange
var repository = new Mock();

repository
    .Setup(x => x.ExistsByEmailAsync(
        It.IsAny<string>(),
        It.IsAny<CancellationToken>()))
    .ReturnsAsync(true);

var service = new UserService(repository.Object);

var request = new RegisterUserRequest(
    "test@example.com");

// Act
var result = await service.RegisterAsync(
    request,
    CancellationToken.None);

// Assert
Assert.True(result.IsFailure);

Assert.Equal(
    "Users.EmailAlreadyExists",
    result.Error!.Code);

Assert.Equal(
    ErrorType.Conflict,
    result.Error.Type);

repository.Verify(
    x => x.AddAsync(
        It.IsAny<User>(),
        It.IsAny<CancellationToken>()),
    Times.Never);
Enter fullscreen mode Exit fullscreen mode

}

Το test ελέγχει όχι μόνο ότι η λειτουργία απέτυχε, αλλά και ότι απέτυχε για τον σωστό λόγο.

Επίσης, επιβεβαιώνει ότι δεν επιχειρήθηκε αποθήκευση χρήστη.

Testing του Success Path

[Fact]
public async Task RegisterAsync_ShouldReturnUser_WhenEmailIsAvailable()
{
// Arrange
var repository = new Mock();

repository
    .Setup(x => x.ExistsByEmailAsync(
        It.IsAny<string>(),
        It.IsAny<CancellationToken>()))
    .ReturnsAsync(false);

var service = new UserService(repository.Object);

var request = new RegisterUserRequest(
    "test@example.com");

// Act
var result = await service.RegisterAsync(
    request,
    CancellationToken.None);

// Assert
Assert.True(result.IsSuccess);

Assert.NotEqual(
    Guid.Empty,
    result.Value.UserId);

Assert.Equal(
    "test@example.com",
    result.Value.Email);

repository.Verify(
    x => x.AddAsync(
        It.IsAny<User>(),
        It.IsAny<CancellationToken>()),
    Times.Once);
Enter fullscreen mode Exit fullscreen mode

}

Με αυτόν τον τρόπο, τα tests περιγράφουν καθαρά το αναμενόμενο συμβόλαιο του use case.

Φυσικά, εξακολουθούμε να χρειαζόμαστε integration tests για πραγματικά database constraints, transaction behavior και HTTP mappings.

  1. Result Pattern και Performance

Ένα επιχείρημα που συχνά αναφέρεται υπέρ του Result Pattern είναι η απόδοση.

Η δημιουργία και το throwing ενός exception έχουν κόστος, ειδικά επειδή περιλαμβάνουν stack trace και μηχανισμό exception propagation.

Αν χρησιμοποιούμε exceptions για συχνές, αναμενόμενες αποτυχίες, αυτό το κόστος μπορεί να γίνει σημαντικό.

Ένα Result αντικείμενο συνήθως έχει πιο προβλέψιμο κόστος.

Ωστόσο, χρειάζεται προσοχή.

Το Result Pattern δεν είναι αυτόματα ταχύτερο σε κάθε σενάριο.

Μια class-based υλοποίηση όπως η δική μας δημιουργεί allocations.

Μια struct-based υλοποίηση μπορεί να μειώσει allocations, αλλά εισάγει άλλες προκλήσεις, όπως default struct states και μεγαλύτερο κόστος αντιγραφής.

Επίσης, αν μια αποτυχία συμβαίνει εξαιρετικά σπάνια, η διαφορά μπορεί να είναι πρακτικά αμελητέα.

Σε εφαρμογές με σημαντικές απαιτήσεις απόδοσης, η σωστή προσέγγιση είναι να χρησιμοποιούμε benchmarks με αντιπροσωπευτικά workloads.

Το βασικό όφελος του Result Pattern είναι πρωτίστως η σαφήνεια του συμβολαίου και η αρχιτεκτονική συνέπεια.

Η απόδοση μπορεί να αποτελέσει επιπλέον πλεονέκτημα, αλλά δεν πρέπει να είναι το μοναδικό κριτήριο επιλογής.

  1. Συνηθισμένα λάθη

16.1 Μετατροπή κάθε exception σε Result

Αν κάθε catch καταλήγει σε Result.Failure, υπάρχει κίνδυνος να κρύβουμε πραγματικά bugs.

Για παράδειγμα:

try
{
await SaveAsync();
}
catch (Exception)
{
return Result.Failure(
Error.Failure(
"General.Error",
"Something went wrong."));
}

Ο κώδικας χάνει σημαντική πληροφορία για την πραγματική αιτία της αποτυχίας.

Η σωστή στρατηγική είναι να μετατρέπουμε σε Result μόνο τις αποτυχίες που γνωρίζουμε πώς να αναγνωρίσουμε και να χειριστούμε.

16.2 Επιστροφή HTTP errors από το Domain

Το domain δεν πρέπει να γνωρίζει ότι ένα error αντιστοιχεί σε HTTP 404 ή HTTP 409.

Το HTTP mapping ανήκει στο presentation layer.

16.3 Χρήση ενός γενικού error για τα πάντα

Αν όλα τα failures επιστρέφουν:

Error.Failure(
"Error",
"Operation failed");

χάνουμε μεγάλο μέρος της αξίας του Result Pattern.

Τα errors πρέπει να έχουν συγκεκριμένη σημασιολογία.

16.4 Υπερβολικά πολύπλοκη υλοποίηση

Δεν χρειάζεται κάθε εφαρμογή δεκάδες extensions, operators και functional abstractions.

Μια μικρή εφαρμογή μπορεί να καλυφθεί από ένα απλό Result και έναν mapper.

Η πολυπλοκότητα πρέπει να προστίθεται όταν υπάρχει πραγματική ανάγκη.

16.5 Αγνόηση των failed Results

Ένα Result μπορεί να επιστραφεί και να αγνοηθεί:

await ProcessOrderAsync(orderId);

Αν η μέθοδος επιστρέφει Task, ο compiler δεν απαιτεί να ελεγχθεί το αποτέλεσμα.

Αυτό σημαίνει ότι το Result Pattern απαιτεί και πειθαρχία από την ομάδα.

Code reviews, conventions και κατάλληλοι analyzers μπορούν να βοηθήσουν στον εντοπισμό τέτοιων περιπτώσεων.

  1. Πότε αξίζει να χρησιμοποιήσουμε το Result Pattern;

Το Result Pattern είναι ιδιαίτερα χρήσιμο όταν:

Έχουμε αρκετούς επιχειρησιακούς κανόνες που μπορούν να οδηγήσουν σε αναμενόμενες αποτυχίες.

Θέλουμε να διαχωρίσουμε την επιχειρησιακή λογική από το HTTP layer.

Χρησιμοποιούμε Clean Architecture ή CQRS.

Θέλουμε συνεπή error contracts.

Χρειαζόμαστε προβλέψιμο error handling ανάμεσα σε διαφορετικά application layers.

Θέλουμε να γράφουμε tests που ελέγχουν ρητά τις διαφορετικές εκβάσεις ενός use case.

Δεν είναι όμως απαραίτητο να χρησιμοποιείται παντού.

Σε μια πολύ απλή εφαρμογή CRUD, με ελάχιστους επιχειρησιακούς κανόνες, μπορεί να εισάγει περισσότερη πολυπλοκότητα από όση αφαιρεί.

Επίσης, δεν χρειάζεται κάθε private helper method να επιστρέφει Result.

Μια συνάρτηση που εκτελεί έναν απλό μαθηματικό υπολογισμό ή έναν εγγυημένα έγκυρο μετασχηματισμό μπορεί να συνεχίσει να επιστρέφει απευθείας την τιμή της.

Το Result Pattern έχει μεγαλύτερη αξία στα σημεία όπου η αποτυχία αποτελεί ουσιαστικό μέρος του συμβολαίου.

  1. Custom Result ή έτοιμη βιβλιοθήκη;

Μέχρι τώρα υλοποιήσαμε μια δική μας έκδοση.

Αυτό είναι ιδιαίτερα χρήσιμο για να κατανοήσουμε τις αρχές του pattern.

Σε πραγματικά projects, όμως, υπάρχουν και ώριμες βιβλιοθήκες που προσφέρουν παρόμοια abstractions.

Ενδεικτικά:

FluentResults

ErrorOr

Ardalis.Result

CSharpFunctionalExtensions

Η επιλογή εξαρτάται από τις ανάγκες της εφαρμογής.

Μια έτοιμη βιβλιοθήκη μπορεί να προσφέρει richer error models, composition operators, validation support και integration helpers.

Από την άλλη πλευρά, ένα μικρό custom implementation μπορεί να είναι ευκολότερο στην κατανόηση και να προσφέρει πλήρη έλεγχο του συμβολαίου.

Δεν υπάρχει μία σωστή επιλογή για όλες τις ομάδες.

Πριν επιλέξουμε βιβλιοθήκη, αξίζει να εξετάσουμε την πολυπλοκότητα του API της, τη συμβατότητα με το architecture μας, τη συντήρηση, τις εξαρτήσεις και το πόσο εύκολα μπορούμε να τη χρησιμοποιήσουμε με συνέπεια.

  1. Τελικές σκέψεις

Το Result Pattern δεν είναι απλώς ένας διαφορετικός τρόπος να επιστρέφουμε errors.

Είναι ένας τρόπος να κάνουμε τα συμβόλαια των λειτουργιών μας πιο ξεκάθαρα.

Αντί μια μέθοδος να φαίνεται ότι επιστρέφει μόνο μια τιμή, ενώ στην πραγματικότητα μπορεί να αποτύχει για πολλούς αναμενόμενους λόγους, επιστρέφει ένα αντικείμενο που αναπαριστά ρητά την επιτυχία ή την αποτυχία.

Αυτό μας επιτρέπει να σχεδιάζουμε εφαρμογές στις οποίες:

Οι επιχειρησιακές αποτυχίες είναι σαφώς ορισμένες.

Τα exceptions διατηρούν τον ρόλο τους για μη αναμενόμενες καταστάσεις.

Το application layer παραμένει ανεξάρτητο από το HTTP.

Οι controllers είναι απλούστεροι.

Τα error contracts είναι πιο συνεπή.

Τα unit tests περιγράφουν καθαρά τις διαφορετικές εκβάσεις.

Ωστόσο, το Result Pattern δεν αντικαθιστά το σωστό domain modeling, το validation, τα database constraints, τα transactions ή το exception handling.

Ούτε αποτελεί λόγο να προσθέτουμε abstractions σε κάθε σημείο της εφαρμογής.

Η αξία του βρίσκεται στη σωστή χρήση του.

Ένας καλός σχεδιασμός δεν προσπαθεί να εξαφανίσει κάθε πιθανότητα αποτυχίας. Προσπαθεί να κάνει τις αναμενόμενες αποτυχίες σαφείς, προβλέψιμες και εύκολες στον χειρισμό.

Και αυτό είναι τελικά το σημαντικότερο που μας προσφέρει το Result Pattern σε μια σύγχρονη εφαρμογή ASP.NET Core.

Top comments (0)