Skip to content

Utilities

Four packages providing cross-cutting utility services. Granit.Validation wraps FluentValidation with structured error codes and international validators (IBAN, BIC, E.164, ISO 3166). Granit.Validation.Europe adds France/Belgium-specific regulatory validators. Granit.Timing provides testable time abstractions with per-request timezone support. Granit.Guids generates sequential GUIDs optimized for clustered database indexes.

  • DirectoryGranit.Timing/ IClock, ICurrentTimezoneProvider, TimeProvider bridge
    • Granit.Guids IGuidGenerator, sequential GUIDs
  • DirectoryGranit.Validation/ GranitValidator, international validators, endpoint filter
    • Granit.Validation.Europe France/Belgium regulatory validators
PackageRoleDepends on
Granit.TimingIClock, ICurrentTimezoneProvider, TimeProviderGranit.Core
Granit.GuidsIGuidGenerator, sequential GUIDs for clustered indexesGranit.Timing
Granit.ValidationGranitValidator<T>, international validators, endpoint filterGranit.ExceptionHandling, Granit.Localization
Granit.Validation.EuropeFrance/Belgium-specific validators (NISS, NIR, SIREN, VAT)Granit.Validation, Granit.Localization
graph TD
    T[Granit.Timing] --> CO[Granit.Core]
    G[Granit.Guids] --> T
    V[Granit.Validation] --> EH[Granit.ExceptionHandling]
    V --> L[Granit.Localization]
    VE[Granit.Validation.Europe] --> V
    VE --> L

FluentValidation integration with structured error codes. All validators inherit from GranitValidator<T> which enforces CascadeMode.Continue (all errors returned in a single response). Error messages are replaced with structured codes (Granit:Validation:*) that the SPA resolves from its localization dictionary.

[DependsOn(typeof(GranitValidationModule))]
public class AppModule : GranitModule { }

The module auto-discovers all IValidator<T> implementations from loaded module assemblies. Manual registration is only needed for modules without the Wolverine handler attribute:

services.AddGranitValidatorsFromAssemblyContaining<CreatePatientRequestValidator>();
public record CreatePatientRequest(
string FirstName,
string LastName,
string Email,
string Phone,
string CountryCode,
string Iban);
public class CreatePatientRequestValidator : GranitValidator<CreatePatientRequest>
{
public CreatePatientRequestValidator()
{
RuleFor(x => x.FirstName).NotEmpty().MaximumLength(100);
RuleFor(x => x.LastName).NotEmpty().MaximumLength(100);
RuleFor(x => x.Email).Email();
RuleFor(x => x.Phone).E164Phone();
RuleFor(x => x.CountryCode).Iso3166Alpha2CountryCode();
RuleFor(x => x.Iban).Iban();
}
}

Apply validation to Minimal API endpoints with .ValidateBody<T>():

app.MapPost("/api/v1/patients", CreatePatient)
.ValidateBody<CreatePatientRequest>();

When validation fails, the filter returns 422 Unprocessable Entity with a HttpValidationProblemDetails body:

{
"status": 422,
"errors": {
"Email": ["Granit:Validation:InvalidEmail"],
"Phone": ["Granit:Validation:InvalidE164Phone"],
"Iban": ["Granit:Validation:InvalidIban"]
}
}

Extension methods on IRuleBuilder<T, string?> for common international formats:

CategoryValidatorFormatError code
Contact.Email()RFC 5321 practical subsetInvalidEmail
Contact.E164Phone()+ followed by 7-15 digitsInvalidE164Phone
Payment.Iban()ISO 13616 (MOD-97 check)InvalidIban
Payment.BicSwift()ISO 9362 (8 or 11 chars)InvalidBicSwift
Payment.SepaCreditorIdentifier()EPC262-08 (MOD 97-10)InvalidSepaCreditorIdentifier
Locale.Iso3166Alpha2CountryCode()2 uppercase lettersInvalidIso3166Alpha2
Locale.Bcp47LanguageTag()fr, fr-BE, zh-Hans-CNInvalidBcp47LanguageTag

All error codes are prefixed with Granit:Validation: (omitted in the table for brevity).

Use .WithErrorCodeAndMessage() to set both error code and message to the same value, preventing them from silently diverging:

RuleFor(x => x.AppointmentDate)
.GreaterThan(DateTimeOffset.UtcNow)
.WithErrorCodeAndMessage("Appointments:DateMustBeFuture");

EU-specific regulatory validators for France and Belgium. Separate package to avoid pulling regulatory dependencies into applications that do not need them.

[DependsOn(typeof(GranitValidationEuropeModule))]
public class AppModule : GranitModule { }
public class RegisterDoctorRequestValidator : GranitValidator<RegisterDoctorRequest>
{
public RegisterDoctorRequestValidator()
{
RuleFor(x => x.Niss).BelgianNiss();
RuleFor(x => x.InamiNumber).BelgianInami();
RuleFor(x => x.Vat).EuropeanVat();
RuleFor(x => x.PostalCode).BelgianPostalCode();
}
}
public class RegisterClinicRequestValidator : GranitValidator<RegisterClinicRequest>
{
public RegisterClinicRequestValidator()
{
RuleFor(x => x.Siret).FrenchSiret();
RuleFor(x => x.Vat).FrenchVat();
RuleFor(x => x.Finess).FrenchFiness();
RuleFor(x => x.PostalCode).FrenchPostalCode();
}
}

Personal identifiers:

ValidatorCountryFormatCheck algorithm
.BelgianNiss()BE11 digits (NISS/INSZ/SSIN)MOD 97 (pre-2000 and post-2000)
.FrenchNir()FR15 chars (NIR / Securite Sociale)MOD 97, Corse support (2A/2B)
.BelgianEid()BE12 digits (eID card number)MOD 97 check pair

Company identifiers:

ValidatorCountryFormatCheck algorithm
.FrenchSiren()FR9 digitsLuhn
.FrenchSiret()FR14 digits (SIREN + NIC)Luhn over all 14 digits
.BelgianBce()BE10 digits (BCE/KBO)MOD 97 check pair
.FrenchNafCode()FR4 digits + 1 letter (NAF/APE)Format only

Tax identifiers:

ValidatorCountryFormatCheck algorithm
.BelgianVat()BEBE + 10 digitsBCE algorithm
.FrenchVat()FRFR + 2-digit key + 9-digit SIRENKey = (12 + 3 * (SIREN % 97)) % 97
.EuropeanVat()EUCountry prefix + national formatDispatches per country (algorithmic for FR/BE, format for others)

Payment identifiers:

ValidatorCountryFormatCheck algorithm
.FrenchRib()FR23 chars (bank + branch + account + key)97 - (89*bank + 15*branch + 3*account) % 97
.BelgianAccountNumber()BENNN-NNNNNNN-NN (legacy pre-IBAN)first 10 digits % 97

Professional registries (healthcare):

ValidatorCountryFormatCheck algorithm
.FrenchRpps()FR11 digits (RPPS)Luhn
.FrenchAdeli()FR9 digits (ADELI)Format + length
.FrenchFiness()FR9 digits (FINESS)Luhn
.BelgianInami()BE11 digits (INAMI/RIZIV)MOD 97 check pair

Postal addresses:

ValidatorCountryFormatNotes
.FrenchPostalCode()FR5 digits (01000-99999)Includes Corse (20xxx) and DOM-TOM (97xxx, 98xxx)
.BelgianPostalCode()BE4 digits (1000-9999)
.FrenchInseeCode()FR5 chars (dept + commune)Supports 2A/2B (Corse), DOM 971-976

Testable time abstraction for the entire framework. Replaces all calls to DateTime.Now / DateTime.UtcNow with injectable services. Uses AsyncLocal for per-request timezone propagation.

[DependsOn(typeof(GranitTimingModule))]
public class AppModule : GranitModule { }

The primary abstraction for accessing the current time and performing timezone conversions:

public interface IClock
{
DateTimeOffset Now { get; }
bool SupportsMultipleTimezone { get; }
DateTimeOffset Normalize(DateTimeOffset dateTime);
DateTimeOffset ConvertToUserTime(DateTimeOffset utcDateTime);
DateTimeOffset ConvertToUtc(DateTimeOffset dateTime);
}

Usage in a service:

public class AppointmentService(IClock clock, AppDbContext db)
{
public async Task<bool> IsAvailableAsync(
Guid doctorId, DateTimeOffset requestedTime, CancellationToken cancellationToken)
{
DateTimeOffset utcTime = clock.Normalize(requestedTime);
return !await db.Appointments
.AnyAsync(a => a.DoctorId == doctorId
&& a.ScheduledAt <= utcTime
&& a.EndAt > utcTime,
cancellationToken)
.ConfigureAwait(false);
}
public Appointment CreateAppointment(Guid doctorId, DateTimeOffset scheduledAt)
{
return new Appointment
{
DoctorId = doctorId,
ScheduledAt = clock.Normalize(scheduledAt),
CreatedAt = clock.Now, // Always UTC
};
}
}

Per-request timezone context backed by AsyncLocal<string?>. Set it early in the request pipeline (e.g., from a header, claim, or user preference):

app.Use(async (context, next) =>
{
var timezoneProvider = context.RequestServices.GetRequiredService<ICurrentTimezoneProvider>();
timezoneProvider.Timezone = context.Request.Headers["X-Timezone"].FirstOrDefault()
?? "Europe/Brussels";
await next(context).ConfigureAwait(false);
});

Then IClock.ConvertToUserTime() converts UTC dates to the user’s local time:

public class AppointmentResponse
{
public required DateTimeOffset ScheduledAtUtc { get; init; }
public required DateTimeOffset ScheduledAtLocal { get; init; }
}
// In the handler:
var response = new AppointmentResponse
{
ScheduledAtUtc = appointment.ScheduledAt,
ScheduledAtLocal = clock.ConvertToUserTime(appointment.ScheduledAt),
};

Opt out of automatic UTC normalization for properties that store user-local times (e.g., birth dates where timezone is irrelevant):

public class Patient
{
[DisableDateTimeNormalization]
public DateTimeOffset DateOfBirth { get; set; }
}

Replace TimeProvider in tests to control time:

var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 3, 15, 10, 0, 0, TimeSpan.Zero));
services.AddSingleton<TimeProvider>(fakeTime);
// IClock.Now will return 2026-03-15T10:00:00Z
// Advance: fakeTime.Advance(TimeSpan.FromMinutes(30));
ServiceLifetimeRationale
TimeProviderSingletonTimeProvider.System is stateless and thread-safe
ICurrentTimezoneProviderSingletonBacked by AsyncLocal — value is per-async-context
IClockSingletonStateless — delegates to TimeProvider and ICurrentTimezoneProvider
PropertyDefaultDescription
DefaultTimezonenullFallback timezone when none is set per-request (IANA or Windows ID)

Sequential GUID generator optimized for clustered database indexes. Random GUIDs fragment B-tree indexes because inserts land at random pages. Sequential GUIDs place the timestamp at the front (or end, for SQL Server) so new rows are always appended to the last page, eliminating page splits.

[DependsOn(typeof(GranitGuidsModule))]
public class AppModule : GranitModule { }
public class PatientService(IGuidGenerator guidGenerator, AppDbContext db)
{
public Patient Create(string firstName, string lastName)
{
return new Patient
{
Id = guidGenerator.Create(), // Sequential GUID
FirstName = firstName,
LastName = lastName,
};
}
}
public interface IGuidGenerator
{
Guid Create();
}

Two implementations:

ImplementationStrategyDI registration
SequentialGuidGeneratorTimestamp (6 bytes from IClock) + 10 cryptographic random bytesDefault (singleton)
SimpleGuidGeneratorDelegates to Guid.NewGuid()Available via SimpleGuidGenerator.Instance for non-DI contexts

The byte layout depends on the database engine:

TypeTimestamp positionDatabase
SequentialAsStringFront (sorted by Guid.ToString())PostgreSQL, MySQL
SequentialAsBinaryFront (sorted by Guid.ToByteArray())Oracle
SequentialAtEndEnd (Data4 block)SQL Server

Default: SequentialAsString (optimized for PostgreSQL).

block-beta
    columns 16
    block:asstring["SequentialAsString (PostgreSQL)"]
        columns 16
        t1["T"] t2["T"] t3["T"] t4["T"] t5["T"] t6["T"]
        r1["R"] r2["R"] r3["R"] r4["R"] r5["R"] r6["R"] r7["R"] r8["R"] r9["R"] r10["R"]
    end

    block:atend["SequentialAtEnd (SQL Server)"]
        columns 16
        s1["R"] s2["R"] s3["R"] s4["R"] s5["R"] s6["R"] s7["R"] s8["R"] s9["R"] s10["R"]
        s11["T"] s12["T"] s13["T"] s14["T"] s15["T"] s16["T"]
    end

    style t1 fill:#4a9eff
    style t2 fill:#4a9eff
    style t3 fill:#4a9eff
    style t4 fill:#4a9eff
    style t5 fill:#4a9eff
    style t6 fill:#4a9eff
    style s11 fill:#4a9eff
    style s12 fill:#4a9eff
    style s13 fill:#4a9eff
    style s14 fill:#4a9eff
    style s15 fill:#4a9eff
    style s16 fill:#4a9eff

T = timestamp bytes (6 bytes, millisecond precision from IClock), R = cryptographically random bytes (10 bytes).

services.AddGranitGuids(options =>
{
options.DefaultSequentialGuidType = SequentialGuidType.SequentialAtEnd; // SQL Server
});
PropertyDefaultDescription
DefaultSequentialGuidTypeSequentialAsStringSequential GUID byte layout

CategoryKey typesPackage
ModulesGranitValidationModule, GranitValidationEuropeModule, GranitTimingModule, GranitGuidsModule
ValidationGranitValidator<T>, FluentValidationEndpointFilter<T>, .ValidateBody<T>(), .WithErrorCodeAndMessage()Granit.Validation
International validators.Email(), .E164Phone(), .Iban(), .BicSwift(), .SepaCreditorIdentifier(), .Iso3166Alpha2CountryCode(), .Bcp47LanguageTag()Granit.Validation
European validators.BelgianNiss(), .FrenchNir(), .BelgianEid(), .FrenchSiren(), .FrenchSiret(), .BelgianBce(), .FrenchNafCode(), .BelgianVat(), .FrenchVat(), .EuropeanVat(), .FrenchRib(), .BelgianAccountNumber(), .FrenchRpps(), .FrenchAdeli(), .FrenchFiness(), .BelgianInami(), .FrenchPostalCode(), .BelgianPostalCode(), .FrenchInseeCode()Granit.Validation.Europe
TimingIClock, ICurrentTimezoneProvider, CurrentTimezoneProvider, Clock, ClockOptions, DisableDateTimeNormalizationAttributeGranit.Timing
GUIDsIGuidGenerator, SequentialGuidGenerator, SimpleGuidGenerator, SequentialGuidType, GuidGeneratorOptionsGranit.Guids
ExtensionsAddGranitValidation(), AddGranitValidatorsFromAssemblyContaining<T>(), AddGranitTiming(), AddGranitGuids()