Σε μια εφαρμογή C# με ASP.NET Core, δύο δηλώσεις μπορούν να φαίνονται σχεδόν ισοδύναμες:

public string Name { get; init; } = null!;
public string Name { get; init; } = string.Empty;

Και οι δύο αποφεύγουν την προειδοποίηση για μη αρχικοποιημένη non-nullable ιδιότητα. Και στις δύο περιπτώσεις, ένας validator μπορεί να απορρίψει την αρχική τιμή.

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

Για ένα DTO όπου το πεδίο είναι υποχρεωτικό κείμενο και η απουσία τιμής αντιμετωπίζεται όπως το κενό, το string.Empty είναι συνήθως πιο συνεπής και πιο κατανοητή προεπιλογή. Δίνει εξαρχής μια πραγματική συμβολοσειρά, ενώ αφήνει στον validator την ευθύνη να ελέγξει αν το περιεχόμενό της είναι αποδεκτό.

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

Ας το δούμε πρώτα χωρίς τεχνικούς όρους.

Ένα DTO —Data Transfer Object— είναι ένα αντικείμενο που μεταφέρει δεδομένα. Για παράδειγμα, μεταφέρει από μια φόρμα εγγραφής στο σύστημα το όνομα και το email ενός πελάτη.

Μπορούμε να το φανταστούμε σαν έναν φάκελο με συγκεκριμένες θέσεις για έγγραφα.

Με το string.Empty, στη θέση του ονόματος υπάρχει ένα άδειο φύλλο. Ο έλεγχος πληρότητας θα διαπιστώσει ότι δεν έχει συμπληρωθεί.

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

Ο τελικός έλεγχος μπορεί να απορρίψει και τις δύο περιπτώσεις. Μέχρι να φτάσουμε εκεί, όμως, η κατάσταση του φακέλου διαφέρει.

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

Πριν προχωρήσουμε, χρειάζεται μια μικρή διευκρίνιση, το string.Empty αντιστοιχεί στο "", δηλαδή σε συμβολοσειρά μηδενικού μήκους. Το " " περιέχει έναν χαρακτήρα κενού. Είναι διαφορετικές τιμές, παρότι συχνά απορρίπτονται από τον ίδιο κανόνα. Τεκμηρίωση του String.Empty.

Όταν είναι ενεργοποιημένα τα nullable reference types, η δήλωση

public string Name { get; init; }

εκφράζει την πρόθεση ότι το Name δεν πρέπει να είναι null. Ο compiler χρησιμοποιεί αυτή την πληροφορία για να εντοπίζει πιθανές ασυνέπειες.

Η δήλωση δεν λέει ότι το όνομα έχει περιεχόμενο, ότι είναι σωστά γραμμένο ή ότι πληροί τους επιχειρησιακούς κανόνες. Επίσης, δεν αποτελεί από μόνη της εγγύηση κατά την εκτέλεση ότι καμία εξωτερική διαδικασία δεν θα αναθέσει null. Nullable reference types στη C#.

Επομένως, έχουμε δύο διαφορετικές απαιτήσεις:

  • Απαίτηση τύπου: περιμένουμε μια συμβολοσειρά αντί για null.
  • Απαίτηση περιεχομένου: περιμένουμε ένα αποδεκτό όνομα αντί για κενό ή άχρηστο κείμενο.

Το string.Empty συμβαδίζει με την πρώτη απαίτηση. Η δεύτερη παραμένει ευθύνη του validation.

Ας συγκρίνουμε δύο DTO. Τα παραδείγματα υποθέτουν ότι στο

project είναι ενεργό το enable.

public sealed class CreateCustomerDtoWithNull { public string Name { get; init; } = null!; public string Email { get; init; } = null!; } public sealed class CreateCustomerDto { public string Name { get; init; } = string.Empty; public string Email { get; init; } = string.Empty; } 

Στην πρώτη εκδοχή, το > ! είναι ο > null-forgiving operator. Καταστέλλει τις σχετικές nullable προειδοποιήσεις του compiler. Δεν μετατρέπει το null σε συμβολοσειρά, δεν προσθέτει έλεγχο και δεν προγραμματίζει κάποια μελλοντική ανάθεση. Κατά την εκτέλεση, το null! παραμένει null. Τεκμηρίωση του null-forgiving operator.

Αυτό φαίνεται αμέσως:

var first = new CreateCustomerDtoWithNull();
var second = new CreateCustomerDto();
Console.WriteLine(first.Name is null); // True
Console.WriteLine(second.Name is null); // False
Console.WriteLine(second.Name.Length); // 0

// Αν εκτελεστεί, προκαλεί NullReferenceException:
// Console.WriteLine(first.Name.Length);

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

Το null! συνοδεύεται συχνά από τη σκέψη:
«Θα συμπληρωθεί αργότερα, οπότε προς το παρόν αγνόησε την προειδοποίηση».

Το «θα συμπληρωθεί αργότερα» όμως είναι παραδοχή της υλοποίησης. Δεν αποτελεί συμπεριφορά που εξασφαλίζει ο συγκεκριμένος τελεστής.

Αντίθετα, με το string.Empty μπορούμε να περιγράψουμε την αρχική κατάσταση χωρίς πρόσθετη υπόθεση.

«Το πεδίο ξεκινά με κενό κείμενο και πρέπει να ελεγχθεί πριν χρησιμοποιηθεί για τη συγκεκριμένη λειτουργία».

Αυτή είναι η έννοια του «πιο ειλικρινούς» κώδικα: μικρότερη απόσταση ανάμεσα σε αυτό που δηλώνουμε και σε αυτό που πράγματι δημιουργούμε.

Η ένσταση ότι «το FluentValidation θα τα πιάσει και τα δύο» είναι σωστή, εφόσον χρησιμοποιούμε τον κατάλληλο κανόνα και πράγματι εκτελούμε το validation.

Για ένα υποχρεωτικό όνομα:

using FluentValidation; public sealed class CreateCustomerDtoValidator : AbstractValidator<CreateCustomerDto> { public CreateCustomerDtoValidator() { RuleFor(x => x.Name) .Cascade(CascadeMode.Stop) .NotEmpty() .WithMessage("Το όνομα είναι υποχρεωτικό.") .MaximumLength(100) .WithMessage( "Το όνομα δεν μπορεί να ξεπερνά τους 100 χαρακτήρες."); RuleFor(x => x.Email) .Cascade(CascadeMode.Stop) .NotEmpty() .WithMessage("Το email είναι υποχρεωτικό.") .EmailAddress() .WithMessage("Το email δεν έχει αποδεκτή μορφή."); } } 

Για συμβολοσειρές, το NotEmpty() απορρίπτει το null, το "" και κείμενο που αποτελείται αποκλειστικά από whitespace, όπως " ". Το NotNull() ελέγχει μόνο την απουσία αναφοράς. Ενσωματωμένοι validators του FluentValidation.

Τιμή NotNull() NotEmpty()
null Αποτυχία Αποτυχία
string.Empty, δηλαδή "" Επιτυχία Αποτυχία
" " Επιτυχία Αποτυχία
"Μαρία" Επιτυχία Επιτυχία

Ο ίδιος κανόνας NotEmpty() μπορεί να χρησιμοποιηθεί και για την εκδοχή του DTO με null!.

Άρα, η προτίμηση προς το string.Empty δεν βασίζεται στο ότι ο validator αδυνατεί να εντοπίσει το null. Βασίζεται στη συνέπεια της αρχικής κατάστασης του αντικειμένου και στη σαφήνεια της πρόθεσης.

Χρειάζεται επίσης να ξεχωρίσουμε τον έλεγχο από τη διόρθωση. Το FluentValidation δεν συμπληρώνει από μόνο του το όνομα και δεν αντικαθιστά το null με μια έγκυρη τιμή. Ο validator αναφέρει αν τα δεδομένα παραβιάζουν τους κανόνες. Η εφαρμογή αποφασίζει πώς θα χειριστεί την αποτυχία.

Σε μια εφαρμογή με Clean Architecture, αυτή η διάκριση βοηθά να τοποθετήσουμε σωστά τις ευθύνες.

Μια ενδεικτική οργάνωση είναι:

**MyApp.Api
Endpoints/
CustomerEndpoints.cs

MyApp.Application
Customers/
CreateCustomerDto.cs
CreateCustomerDtoValidator.cs
CreateCustomerHandler.cs
ICustomerRepository.cs

MyApp.Domain
Customers/
Customer.cs

MyApp.Infrastructure
Persistence/
CustomerRepository.cs**

Στο συγκεκριμένο παράδειγμα:

  • Το API παραλαμβάνει το HTTP αίτημα και διαμορφώνει την HTTP απάντηση.

  • Το Application συντονίζει τη δημιουργία πελάτη και εκτελεί το validation της εισόδου.

  • Το Domain προστατεύει τους κανόνες που πρέπει να ισχύουν για έναν πελάτη.

  • Το Infrastructure υλοποιεί την αποθήκευση.

Αυτή είναι μία δυνατή οργάνωση. Το βασικό αρχιτεκτονικό στοιχείο είναι ότι η επιχειρησιακή λογική παραμένει στο κέντρο και οι εξαρτήσεις στρέφονται προς αυτό. Η Clean Architecture δεν επιβάλλει συγκεκριμένο initializer για τα DTO. Αρχιτεκτονική καθοδήγηση της Microsoft.

Η ροή εκτέλεσης μπορεί να αποδοθεί απλά:
Αίτημα HTTP
→ Δημιουργία DTO
→ Validation στο Application
→ Δημιουργία αντικειμένου Domain
→ Αποθήκευση
Ο initializer συμμετέχει στη δημιουργία του DTO. Δεν αντικαθιστά κανένα από τα επόμενα βήματα.

Ένας handler μπορεί να εφαρμόζει τη σειρά ρητά:

using FluentValidation; public sealed class CreateCustomerHandler( IValidator<CreateCustomerDto> validator, ICustomerRepository customers) { public async Task<Guid> HandleAsync( CreateCustomerDto dto, CancellationToken cancellationToken) { await validator.ValidateAndThrowAsync( dto, cancellationToken); var customer = Customer.Create( dto.Name, dto.Email); await customers.AddAsync( customer, cancellationToken); return customer.Id; } } 

Το απόσπασμα υποθέτει ότι υπάρχουν η factory Customer.Create στο Domain και το interface ICustomerRepository στο Application, με υλοποίηση στο Infrastructure.

Η τοποθέτηση του validation εδώ σημαίνει ότι ο συγκεκριμένος handler το εκτελεί και όταν καλείται από διαφορετική είσοδο, όπως ένα background job. Σε μεγαλύτερες εφαρμογές, ο ίδιος κανόνας μπορεί να εφαρμόζεται κεντρικά με validation pipeline.

Χρειάζεται βέβαια να έχει δηλωθεί και ο validator στο dependency injection:

builder.Services.AddScoped<IValidator<CreateCustomerDto>, CreateCustomerDtoValidator>(); 

Η καταχώριση τον καθιστά διαθέσιμο για χρήση. Η κλήση του είναι εκείνη που εκτελεί τον έλεγχο. Το FluentValidation υποστηρίζει ρητή εκτέλεση validator σε ASP.NET Core controllers και endpoints. Χρήση του FluentValidation με ASP.NET Core.

Στο παράδειγμα με ValidateAndThrowAsync, το API πρέπει επιπλέον να μεταφράζει το ValidationException σε κατάλληλη απάντηση, όπως HTTP 400 με τα σφάλματα ανά πεδίο. Αυτή η αντιστοίχιση χρειάζεται υλοποίηση, για παράδειγμα σε exception handler.

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

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

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

Το Domain χρειάζεται επίσης τη δική του προστασία. Ένα αντικείμενο Customer μπορεί στο μέλλον να δημιουργηθεί από άλλη λειτουργία, εισαγωγή δεδομένων ή διαφορετικό handler. Οι κανόνες που ορίζουν έναν έγκυρο πελάτη πρέπει να προστατεύονται και κατά τη δημιουργία του.

Το DTO επιτρέπεται να αναπαριστά μια ελλιπή υποβολή. Ο πελάτης που θα αποθηκευτεί πρέπει να ικανοποιεί τους κανόνες του Domain.

Υπάρχει, ωστόσο, ένα όριο που πρέπει να δηλώνεται καθαρά: το string.Empty εγγυάται τη συγκεκριμένη αρχική τιμή, όχι ότι η ιδιότητα δεν θα λάβει ποτέ null.

Για παράδειγμα, ας έρθει το ακόλουθο JSON:

{
"name": null,
"email": "maria@example.com"
}

Με το συνηθισμένο System.Text.Json, χωρίς ενεργή επιβολή nullable annotations ή άλλη σχετική προσαρμογή, το ρητό null μπορεί να ανατεθεί στην ιδιότητα και να αντικαταστήσει την αρχική κενή συμβολοσειρά.

Αν η ιδιότητα λείπει εντελώς από το JSON, ο serializer συνήθως αφήνει την τιμή που όρισε η αρχικοποίηση. Από το .NET 9 υπάρχει η επιλογή RespectNullableAnnotations, η οποία προσθέτει περιορισμένη επιβολή των nullable annotations και μπορεί να απορρίπτει το ρητό null για τη συγκεκριμένη non-nullable ιδιότητα. Nullable annotations στο System.Text.Json.

Επομένως, η ασφαλής διατύπωση είναι:
«Το string.Empty αφαιρεί το αρχικό null που θα δημιουργούσαμε μόνοι μας. Η εξωτερική είσοδος εξακολουθεί να χρειάζεται έλεγχο».

Επιπλέον, σε εφαρμογές με MVC controllers, το ASP.NET Core μπορεί να αντιμετωπίζει non-nullable ιδιότητες σαν να έχουν implicit [Required]. Ανάλογα με τις ρυθμίσεις, ένα αίτημα μπορεί να απορριφθεί πριν φτάσει στον δικό μας validator. Η σειρά των ελέγχων πρέπει να λαμβάνεται υπόψη όταν θέλουμε ομοιόμορφα μηνύματα σφάλματος. Model validation στο ASP.NET Core.

Υπάρχουν επίσης DTO για τα οποία η προεπιλογή string.Empty θα έκρυβε χρήσιμη πληροφορία.

Αν ένα προαιρετικό πεδίο χρησιμοποιεί το null για να δηλώσει «δεν δόθηκε πληροφορία», τότε η nullable δήλωση μπορεί να είναι ακριβέστερη:

public sealed class CustomerDetailsDto { public string? MiddleName { get; init; } } 

Ακόμη και ένα επιχειρησιακά υποχρεωτικό πεδίο μπορεί να είναι nullable στο ακατέργαστο request:

public sealed class CreateCustomerRequest { public string? Name { get; init; } public string? Email { get; init; } } 

Η λογική εδώ είναι ότι το request αποτυπώνει δεδομένα που δεν έχουν ακόμη ελεγχθεί. Ο validator απαιτεί να υπάρχει περιεχόμενο και η εφαρμογή δημιουργεί στη συνέχεια ένα εσωτερικό αντικείμενο με αυστηρότερες εγγυήσεις.

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

Η αντικατάσταση της απουσίας με "" είναι κατάλληλη όταν αυτή η διαφορά δεν έχει σημασία για τη συγκεκριμένη λειτουργία. Αν η παράλειψη ενός πεδίου, το ρητό null και η αποστολή κενού κειμένου έχουν διαφορετική σημασία —όπως μπορεί να συμβαίνει σε μερικές ενημερώσεις— χρειάζεται μοντέλο που διατηρεί αυτές τις διακρίσεις. Ένα απλό string? επίσης δεν διακρίνει μόνο του την παράλειψη από το ρητό null.

Μια ακόμη επιλογή είναι το required:

public sealed class CreateCustomerRequest { public required string Name { get; init; } public required string Email { get; init; } } 

Το required εκφράζει απαίτηση αρχικοποίησης και το System.Text.Json μπορεί να το χρησιμοποιήσει για να απαιτήσει την παρουσία της ιδιότητας στο JSON. Η παρουσία όμως δεν εξασφαλίζει χρήσιμο περιεχόμενο: το "" εξακολουθεί να χρειάζεται απόρριψη από τον κατάλληλο κανόνα. Required properties στο System.Text.Json.

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

Για μια ομάδα ανάπτυξης, μια πρακτική σύμβαση είναι η ακόλουθη:

Σε DTO με υποχρεωτικά πεδία κειμένου, όπου η απουσία και το κενό αντιμετωπίζονται με τον ίδιο τρόπο, χρησιμοποιούμε string.Empty ως προεπιλογή και NotEmpty() για τον έλεγχο. Όταν η απουσία έχει δικό της νόημα, τη μοντελοποιούμε ρητά. Κάθε null! χρειάζεται συγκεκριμένο λόγο που να εξηγεί γιατί η καταστολή της προειδοποίησης είναι ασφαλής.

Η αξία αυτής της σύμβασης βρίσκεται στην καθημερινή συντήρηση, ο επόμενος developer χρειάζεται λιγότερες υποθέσεις για να καταλάβει την αρχική κατάσταση του DTO, ο reviewer έχει σαφέστερο κριτήριο και το validation διατηρεί μια ξεκάθαρη ευθύνη. Η εφαρμογή ξεκινά με την τιμή που δηλώνει και ελέγχει ρητά αν αυτή επαρκεί για την πράξη που ζητήθηκε.

  1. Microsoft Learn — Τι κάνει πραγματικά το null!

Η Microsoft διευκρινίζει ότι ο null-forgiving operator επηρεάζει τη στατική ανάλυση του compiler και δεν έχει επίδραση κατά την εκτέλεση. Επομένως, το = null!; εξακολουθεί να αρχικοποιεί την ιδιότητα με null.

Αυτό τεκμηριώνει τον βασικό ισχυρισμό του άρθρου: η καταστολή της προειδοποίησης δεν δημιουργεί πραγματική non-null τιμή.

Πηγή: ! — null-forgiving operator.

  1. FluentValidation — Το NotEmpty() καλύπτει και τις δύο περιπτώσεις

Η επίσημη τεκμηρίωση αναφέρει ότι, για συμβολοσειρές, το NotEmpty() απορρίπτει null, κενή συμβολοσειρά και whitespace.

Συνεπώς, η προτίμηση προς το string.Empty αφορά τη μοντελοποίηση και την αρχική κατάσταση του DTO. Η δυνατότητα εντοπισμού της μη αποδεκτής τιμής υπάρχει και στις δύο προσεγγίσεις.

Πηγή: Built-in Validators.

Η συμπεριφορά διασταυρώνεται και στον ίδιο τον πηγαίο κώδικα του NotEmptyValidator, όπου υπάρχουν έλεγχοι για null και string.IsNullOrWhiteSpace.

  1. .NET Blog — Η πρόθεση πρέπει να φαίνεται στους τύπους

Ο Mads Torgersen, στο άρθρο Embracing nullable reference types, εξηγεί ότι τα nullable reference types κάνουν εμφανή την πρόθεση σχετικά με το null και προειδοποιούν όταν ο κώδικας δεν την ακολουθεί.

Αυτό στηρίζει την αρχή της συνέπειας ανάμεσα στη δήλωση και τη χρήση μιας τιμής. Το άρθρο δεν θεσπίζει κανόνα ότι όλα τα DTO πρέπει να χρησιμοποιούν string.Empty.

Πηγή: Embracing nullable reference types — Microsoft .NET Blog.

  1. Maarten Balliauw — Κριτική στην αρχικοποίηση DTO με default!

Ο Balliauw εξετάζει ειδικά το πρόβλημα των nullable warnings σε JSON DTOs. Αποθαρρύνει το default!, επειδή μπορεί να αφήσει πραγματικό null χωρίς αντίστοιχη προειδοποίηση. Για reference types όπως το string, αυτό παρουσιάζει το ίδιο βασικό πρόβλημα με το null!.

Προτείνει επίσης εναλλακτικές, όπως nullable ιδιότητες όταν επιτρέπεται η απουσία τιμής και required όταν θέλουμε απαίτηση παρουσίας.

Στηρίζει την κριτική στην απόκρυψη του προβλήματος, αλλά δεν αποτελεί σύσταση υπέρ του string.Empty σε κάθε DTO.

Πηγή: Fix CS8618 Nullable Warnings in C# JSON Models.

  1. Ian Griffiths / endjin — Ο αντίλογος για τις κενές συμβολοσειρές

Ο Griffiths προειδοποιεί ότι η μηχανική αντικατάσταση ενός μη αρχικοποιημένου πεδίου με "" μπορεί απλώς να μεταφέρει το πρόβλημα: η τιμή εξακολουθεί να μην έχει χρήσιμο περιεχόμενο, ενώ ο compiler δεν εντοπίζει αυτή την έλλειψη όπως εντοπίζει πιθανό null.

Αυτός ο αντίλογος περιορίζει σωστά το επιχείρημά μας: το string.Empty είναι εύλογη προεπιλογή για ένα DTO που αναπαριστά μη ελεγμένη είσοδο, όταν υπάρχει υποχρεωτικό validation και το κενό έχει αποδεκτό νόημα σε αυτό το στάδιο. Δεν εξασφαλίζει από μόνο του έγκυρο αντικείμενο.

Πηγή: C# 8.0 nullable references: defeating the point with empty strings.

  1. Microsoft Learn — Ο initializer δεν εμποδίζει την εισαγωγή null από JSON

Η τεκμηρίωση του System.Text.Json διαχωρίζει την απουσία ιδιότητας από το ρητό null. Περιγράφει επίσης την επιλογή RespectNullableAnnotations, διαθέσιμη από το .NET 9.

Αυτό επιβεβαιώνει ότι το string.Empty καθορίζει την αρχική κατάσταση, ενώ η συμπεριφορά απέναντι σε εξωτερικό null εξαρτάται και από τις ρυθμίσεις deserialization.

Πηγή: Respect nullable annotations.

nikosstit@gmail.com