Skip to content

Granit.Core

Granit.Core is the foundation of the entire framework. Every Granit module depends on it. It provides the module system (topological loading, DI orchestration), shared domain-driven design base types, GDPR/ISO 27001 data filtering, domain events, and a structured exception hierarchy.

Package: Granit.Core Dependencies: None (only Microsoft.AspNetCore.App framework reference) Dependents: 24 packages (all other Granit modules)

Granit.Core is a single package — no providers, no EF Core sub-package. It defines abstractions that other modules implement.

  • Directorysrc/Granit.Core/
    • DirectoryModularity/
      • GranitModule.cs
      • GranitBuilder.cs
      • DependsOnAttribute.cs
      • ModuleLoader.cs
    • DirectoryDomain/
      • Entity.cs
      • ValueObject.cs
      • AggregateRoot.cs
      • ISoftDeletable.cs
      • IMultiTenant.cs
      • IActive.cs
      • IPublishable.cs
      • IProcessingRestrictable.cs
    • DirectoryDataFiltering/
      • IDataFilter.cs
      • DataFilter.cs
    • DirectoryMultiTenancy/
      • ICurrentTenant.cs
      • NullTenantContext.cs
    • DirectoryEvents/
      • IDomainEvent.cs
      • IIntegrationEvent.cs
      • IDomainEventDispatcher.cs
    • DirectoryExceptions/
      • BusinessException.cs
      • ValidationException.cs
      • EntityNotFoundException.cs
    • DirectoryEndpoints/
      • ModuleConfigEndpointExtensions.cs
      • BatchResult.cs
    • DirectoryExtensions/
      • GranitHostBuilderExtensions.cs
      • GranitApplicationExtensions.cs
    • DirectoryLocalization/
      • LocalizableString.cs
      • LocalizationResourceNameAttribute.cs
    • DirectoryDiagnostics/
      • GranitActivitySourceRegistry.cs
var builder = WebApplication.CreateBuilder(args);
// Option 1: Single root module (recommended)
builder.AddGranit<AppModule>();
// Option 2: Fluent builder (multi-root)
builder.AddGranit(granit => granit
.AddModule<GranitSecurityModule>()
.AddModule<GranitPersistenceModule>()
.AddModule<AppModule>());
var app = builder.Build();
app.UseGranit();
app.Run();

Both AddGranit and UseGranit have async counterparts for modules that need async initialization (e.g., fetching secrets from Vault at startup):

await builder.AddGranitAsync<AppModule>();
var app = builder.Build();
await app.UseGranitAsync();

Every module inherits from GranitModule and declares dependencies via [DependsOn]:

[DependsOn(typeof(GranitPersistenceModule))]
[DependsOn(typeof(GranitSecurityModule))]
public class AppModule : GranitModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddScoped<IAppointmentService, AppointmentService>();
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
var logger = context.ServiceProvider.GetRequiredService<ILogger<AppModule>>();
logger.LogInformation("AppModule initialized");
}
}

Modules execute in topological order (dependencies first), resolved via Kahn’s algorithm. Circular dependencies throw InvalidOperationException at startup.

sequenceDiagram
    participant Host as Host Builder
    participant ML as ModuleLoader
    participant M1 as GranitCoreModule
    participant M2 as GranitPersistenceModule
    participant M3 as AppModule

    Host->>ML: AddGranit(AppModule)
    ML->>ML: Discover [DependsOn] graph
    ML->>ML: Topological sort (Kahn)
    ML->>M1: ConfigureServices()
    ML->>M2: ConfigureServices()
    ML->>M3: ConfigureServices()
    Note over Host: builder.Build()
    ML->>M1: OnApplicationInitialization()
    ML->>M2: OnApplicationInitialization()
    ML->>M3: OnApplicationInitialization()

Each lifecycle method has sync and async variants. Both are called — sync first, then async.

Passed to ConfigureServices / ConfigureServicesAsync:

PropertyTypeDescription
ServicesIServiceCollectionDI container
ConfigurationIConfigurationappsettings + environment
BuilderIHostApplicationBuilderFull builder access
ModuleAssembliesIReadOnlyList<Assembly>All loaded module assemblies (topological order)
ItemsIDictionary<string, object?>Inter-module state sharing during configuration

Override IsEnabled to conditionally skip a module:

public class GranitNotificationsEmailSmtpModule : GranitModule
{
public override bool IsEnabled(ServiceConfigurationContext context)
{
return context.Configuration.GetValue<bool>("Granit:Notifications:Email:Smtp:Enabled");
}
}

Alternative to [DependsOn] for composing modules in Program.cs:

builder.AddGranit(granit => granit
.AddModule<GranitCoreModule>()
.AddModule<GranitPersistenceModule>()
.AddModule<GranitObservabilityModule>());

Modules are deduplicated — adding the same module twice is safe.

Choose the right base class based on your audit requirements:

Simplest — just an Id. Use for lookup tables and value-like records.

public class Country : Entity
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}

Aggregate roots mirror the entity hierarchy but add domain event support via IDomainEventSource:

Base classAudit levelDomain events
AggregateRootNone (just Id)Yes
CreationAuditedAggregateRootCreationYes
AuditedAggregateRootCreation + modificationYes
FullAuditedAggregateRootFull + soft deleteYes
public class LegalAgreement : FullAuditedAggregateRoot
{
public string Title { get; set; } = string.Empty;
public int Version { get; set; }
public void Sign(string signedBy)
{
// Business logic...
AddDomainEvent(new AgreementSignedEvent(Id, signedBy));
}
}

Equality by value, not by reference. Override GetEqualityComponents():

public class Address : ValueObject
{
public string Street { get; init; } = string.Empty;
public string City { get; init; } = string.Empty;
public string PostalCode { get; init; } = string.Empty;
public string Country { get; init; } = string.Empty;
protected override IEnumerable<object?> GetEqualityComponents()
{
yield return Street;
yield return City;
yield return PostalCode;
yield return Country;
}
}

Five marker interfaces enable automatic EF Core global query filters. Entities implementing these interfaces are filtered transparently — no manual WHERE clauses needed.

InterfaceFilter behaviorGDPR/ISO relevance
ISoftDeletableHides IsDeleted = trueRight to erasure (Art. 17)
IMultiTenantScopes to current TenantIdTenant isolation
IActiveHides IsActive = falseData minimization
IPublishableHides IsPublished = falseDraft/published lifecycle
IProcessingRestrictableHides IsProcessingRestricted = trueRight to restriction (Art. 18)

Selectively disable filters at runtime using IDataFilter:

public class PatientPurgeService(
IDataFilter dataFilter,
AppDbContext dbContext)
{
public async Task PurgeSoftDeletedAsync(CancellationToken cancellationToken)
{
using (dataFilter.Disable<ISoftDeletable>())
{
var deleted = await dbContext.Patients
.Where(p => p.IsDeleted && p.DeletedAt < DateTimeOffset.UtcNow.AddYears(-3))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
dbContext.Patients.RemoveRange(deleted);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
// Filter automatically re-enabled here
}
}

DataFilter is registered as Singleton. It uses AsyncLocal<ImmutableDictionary<Type, bool>> internally — thread-safe, supports nested scopes with automatic restoration.

InterfacePropertiesPurpose
IVersionedBusinessId, VersionVersioned audit history (stable ID across versions)
ITranslatable<T>Translations collectionMulti-language entity content
ITranslationParentId, CultureTranslation record (with Translation<T> and AuditedTranslation<T> base classes)

Translation resolution chain (GetTranslation() extension): exact culture → parent culture (fr-BEfr) → default culture (en) → first available.

ICurrentTenant lives in Granit.Core.MultiTenancy — available in every module without referencing Granit.MultiTenancy.

public interface ICurrentTenant
{
bool IsAvailable { get; }
Guid? Id { get; }
string? Name { get; }
IDisposable Change(Guid? id, string? name = null);
}

By default, NullTenantContext is registered (IsAvailable = false). When Granit.MultiTenancy is installed, it replaces the registration with a real implementation.

TypeInterfaceConventionDelivery
Domain eventIDomainEventXxxOccurred (e.g., PatientDischargedOccurred)In-process, transactional
Integration eventIIntegrationEventXxxEvent (e.g., BedReleasedEvent)Cross-module, durable

Domain events are collected on aggregate roots via AddDomainEvent() and dispatched by IDomainEventDispatcher. The default implementation is NullDomainEventDispatcher (no-op). Granit.Wolverine replaces it with a real dispatcher that routes events through the Wolverine message bus.

All exceptions map to specific HTTP status codes via Granit.ExceptionHandling (RFC 7807 Problem Details):

ExceptionStatusInterfacesUse case
BusinessException400IHasErrorCode, IUserFriendlyExceptionBusiness rule violation
BusinessRuleViolationException422(inherits from BusinessException)Semantic validation failure
ValidationException422IHasValidationErrors, IUserFriendlyExceptionFluentValidation errors
EntityNotFoundException404IUserFriendlyExceptionEntity lookup miss
NotFoundException404IUserFriendlyExceptionGeneral “not found”
ConflictException409IHasErrorCode, IUserFriendlyExceptionConcurrency/duplicate conflict
ForbiddenException403IUserFriendlyExceptionAuthorization denial

Only exceptions implementing IUserFriendlyException expose their message to clients. All others return a generic error message (ISO 27001 — no internal details leak).

// Business rule with error code
throw new BusinessException("Appointment:PastDate", "Cannot schedule in the past");
// Entity not found (generic message to client, details in logs)
throw new EntityNotFoundException(typeof(Patient), patientId);
// Validation errors (from FluentValidation)
throw new ValidationException(new Dictionary<string, string[]>
{
["Email"] = ["Email address is required"],
["DateOfBirth"] = ["Must be in the past"]
});

Expose read-only module configuration to frontend clients:

// 1. Define the response DTO
public record NotificationConfigResponse(bool EmailEnabled, bool SmsEnabled, int MaxRetries);
// 2. Implement the provider
public class NotificationConfigProvider(IOptions<NotificationOptions> options)
: IModuleConfigProvider<NotificationConfigResponse>
{
public NotificationConfigResponse GetConfig() => new(
options.Value.EmailEnabled,
options.Value.SmsEnabled,
options.Value.MaxRetries);
}
// 3. Map the endpoint (in your module's OnApplicationInitialization)
app.MapGranitModuleConfig<NotificationConfigProvider, NotificationConfigResponse>(
routePrefix: "api/notifications",
endpointName: "GetNotificationConfig",
tag: "Notifications");

This maps GET /api/notifications/config → 200 OK with the response DTO.

BatchResult<T> and BatchResultHelper standardize partial-success responses:

var results = new List<BatchItemResult<Patient>>();
foreach (var command in commands)
{
try
{
var patient = await CreatePatientAsync(command, cancellationToken).ConfigureAwait(false);
results.Add(BatchResultHelper.Success(patient));
}
catch (ValidationException ex)
{
results.Add(BatchResultHelper.Failure<Patient>(ex.Message));
}
}
var batch = BatchResultHelper.Create(results);
return batch.ToResult(); // 200 if all succeeded, 207 Multi-Status if any failed

GranitActivitySourceRegistry is a process-global registry for System.Diagnostics.ActivitySource names. Modules call Register("Granit.ModuleName") during ConfigureServices, and Granit.Observability auto-discovers all registered sources at startup for OpenTelemetry tracing.

CategoryTypesCount
Module systemGranitModule, GranitBuilder, DependsOnAttribute, ServiceConfigurationContext, ApplicationInitializationContext, GranitApplication, IModuleConfigProvider<T>, ModuleDescriptor8
Domain base classesEntity, ValueObject, AggregateRoot + Creation/Audited/FullAudited variants10
Filter interfacesISoftDeletable, IMultiTenant, IActive, IPublishable, IProcessingRestrictable5
Data filteringIDataFilter, DataFilter2
EventsIDomainEvent, IIntegrationEvent, IDomainEventSource, IDomainEventDispatcher4
Multi-tenancyICurrentTenant, AllowAnonymousTenantAttribute2
ExceptionsBusinessException, ValidationException, EntityNotFoundException, NotFoundException, ConflictException, ForbiddenException, BusinessRuleViolationException7
Exception interfacesIUserFriendlyException, IHasErrorCode, IHasValidationErrors3
TranslationITranslatable<T>, ITranslation, Translation<T>, AuditedTranslation<T>4
OtherIVersioned, AuditLogEntry, LocalizableString, GranitActivitySourceRegistry, BatchResult<T>5