Skip to content

Data Exchange — CSV, Excel & PDF Export

Granit.DataExchange provides a complete import/export pipeline for tabular data. Imports follow a guided flow: upload a file, preview headers, receive intelligent column mapping suggestions, confirm, then execute with batched persistence and detailed error reporting. Exports use a whitelist-based field definition, optional presets, and automatic background dispatch for large datasets. Both pipelines integrate with Wolverine for durable outbox-backed execution when installed.

  • DirectoryGranit.DataExchange/ Core: import/export pipelines, fluent definitions, mapping suggestion engine
    • Granit.DataExchange.BlobStorage Bridges IDataExchangeFileProvider to IBlobStoreProvider (S3, Azure, FileSystem)
    • Granit.DataExchange.Csv CSV parser (Sep SIMD) and writer (semicolon separator)
    • Granit.DataExchange.Excel Excel parser (Sylvan.Data.Excel) and writer (ClosedXML)
    • Granit.DataExchange.EntityFrameworkCore DataExchangeDbContext, EF executor, identity resolvers, stores
    • Granit.DataExchange.Endpoints REST endpoints for import/export operations
    • Granit.DataExchange.Wolverine Replaces Channel dispatchers with Wolverine outbox-backed dispatchers
    • Granit.DataExchange.Definitions Pre-built ExportDefinition for 39 framework entities
PackageRoleDepends on
Granit.DataExchangeImport/export pipelines, mapping suggestions, fluent definitionsGranit.Timing, Granit.Validation
Granit.DataExchange.BlobStorageBridges file operations to blob storage providersGranit.DataExchange, Granit.BlobStorage
Granit.DataExchange.CsvSep-based CSV parser, semicolon CSV writerGranit.DataExchange
Granit.DataExchange.ExcelSylvan streaming Excel reader, ClosedXML writerGranit.DataExchange
Granit.DataExchange.EntityFrameworkCoreDataExchangeDbContext, EF executor, identity resolversGranit.DataExchange, Granit.Persistence
Granit.DataExchange.Endpoints19 REST endpoints (import + export + metadata)Granit.DataExchange, Granit.Authorization
Granit.DataExchange.WolverineOutbox-backed import/export dispatchGranit.DataExchange, Granit.Wolverine
Granit.DataExchange.DefinitionsPre-built export definitions for 39 framework entitiesGranit.DataExchange, all entity modules
graph TD
    DX[Granit.DataExchange] --> T[Granit.Timing]
    DX --> V[Granit.Validation]
    BS[Granit.DataExchange.BlobStorage] --> DX
    BS --> B[Granit.BlobStorage]
    CSV[Granit.DataExchange.Csv] --> DX
    XLS[Granit.DataExchange.Excel] --> DX
    EF[Granit.DataExchange.EntityFrameworkCore] --> DX
    EF --> P[Granit.Persistence]
    EP[Granit.DataExchange.Endpoints] --> DX
    EP --> A[Granit.Authorization]
    WV[Granit.DataExchange.Wolverine] --> DX
    WV --> W[Granit.Wolverine]
[DependsOn(
typeof(GranitDataExchangeEntityFrameworkCoreModule),
typeof(GranitDataExchangeBlobStorageModule),
typeof(GranitDataExchangeEndpointsModule),
typeof(GranitDataExchangeCsvModule),
typeof(GranitDataExchangeExcelModule),
typeof(GranitDataExchangeWolverineModule))]
public class AppModule : GranitModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
// Register import definitions
context.Services.AddImportDefinition<Patient, PatientImportDefinition>();
// Register export definitions
context.Services.AddExportDefinition<Patient, PatientExportDefinition>();
}
}
// Map endpoints in Program.cs
app.MapGranitDataExchange();
// Or with custom prefix and role
app.MapGranitDataExchange(opts =>
{
opts.RoutePrefix = "admin/data-exchange";
opts.RequiredRole = "ops-team";
});
{
"DataExchange": {
"DefaultMaxFileSizeMb": 50,
"DefaultBatchSize": 500,
"FuzzyMatchThreshold": 0.8
}
}
PropertyDefaultDescription
DefaultMaxFileSizeMb50Max upload size (overridable per definition)
DefaultBatchSize500Rows per SaveChanges batch
FuzzyMatchThreshold0.8Minimum Levenshtein similarity for fuzzy tier (0.0 - 1.0)

Both import and export pipelines need to store files (uploaded CSV/Excel for import, generated output for export). The IDataExchangeFileProvider interface abstracts this concern with three operations: OpenAsync, SaveAsync, and DeleteAsync.

By default, an in-memory implementation is registered — suitable for tests, CLI tools, and development. Data is lost on process restart. For production workloads, register Granit.DataExchange.BlobStorage or a custom implementation.

The simplest approach is to add Granit.DataExchange.BlobStorage, which bridges IDataExchangeFileProvider to the registered blob storage provider (S3, Azure Blob, FileSystem, etc.):

[DependsOn(typeof(GranitDataExchangeBlobStorageModule))]
public class AppModule : GranitModule { }
{
"Granit:DataExchange:BlobStorage": {
"ContainerName": "data-exchange"
}
}
PropertyDefaultDescription
ContainerNamedata-exchangeKey-prefix segment in the object path (not a physical bucket)
DefaultContentTypeapplication/octet-streamMIME type for stored export files

For scenarios where blob storage is not available (CLI tools, local file system, in-memory testing), implement IDataExchangeFileProvider directly and register it as a scoped service:

services.Replace(ServiceDescriptor.Scoped<IDataExchangeFileProvider, MyFileProvider>());
flowchart LR
    A[Upload file] --> B[Extract headers]
    B --> C[Preview rows]
    C --> D["Suggest mappings<br/>4-tier"]
    D --> E["User confirms<br/>mappings"]
    E --> F[Parse rows]
    F --> G[Map to entities]
    G --> H[Validate rows]
    H --> I[Resolve identity]
    I --> J["Execute batch<br/>INSERT / UPDATE"]
    J --> K[Report + correction file]

Each entity requires an ImportDefinition<T> that declares importable properties using a fluent API. Only explicitly declared properties are available for column mapping (whitelist pattern).

public sealed class PatientImportDefinition : ImportDefinition<Patient>
{
public override string Name => "Acme.PatientImport";
protected override void Configure(ImportDefinitionBuilder<Patient> builder)
{
builder
.HasBusinessKey(p => p.Niss)
.Property(p => p.Niss, p => p.DisplayName("NISS").Required())
.Property(p => p.LastName, p => p.DisplayName("Last name").Required())
.Property(p => p.FirstName, p => p.DisplayName("First name").Required())
.Property(p => p.Email, p => p
.DisplayName("Email")
.Aliases("Courriel", "E-mail", "Mail"))
.Property(p => p.BirthDate, p => p
.DisplayName("Date of birth")
.Format("dd/MM/yyyy"))
.ExcludeOnUpdate(p => p.Niss);
}
}

Property configuration options:

MethodDescription
.DisplayName(string)User-facing label (used in preview UI and mapping suggestions)
.Description(string)Sent to the AI mapping service as field metadata
.Aliases(params string[])Alternative names for exact and fuzzy matching
.Required(bool)Import-level required validation (independent of entity [Required])
.Format(string)Expected format for type conversion (e.g. "dd/MM/yyyy")

Identity resolution:

MethodDescription
.HasBusinessKey(p => p.Niss)Single natural key for INSERT vs UPDATE resolution
.HasCompositeKey(p => p.Code, p => p.Year)Multi-column business key
.HasExternalId()External ID column for cross-system identity mapping

Parent/child import:

builder
.GroupBy("InvoiceNumber")
.Property(p => p.InvoiceNumber, p => p.Required())
.Property(p => p.CustomerName)
.HasMany(p => p.Lines, child =>
{
child.Property(l => l.ProductCode, p => p.Required());
child.Property(l => l.Quantity);
child.Property(l => l.UnitPrice);
});

When headers are extracted from the uploaded file, the mapping suggestion service runs four tiers in order. Columns matched by a higher-confidence tier are excluded from lower tiers:

flowchart TD
    H[Source column headers] --> T1
    T1["Tier 1: Saved mappings<br/>Previously confirmed by user"] --> T2
    T2["Tier 2: Exact match<br/>Property name, display name, aliases"] --> T3
    T3["Tier 3: Fuzzy match<br/>Levenshtein distance >= threshold"] --> T4
    T4["Tier 4: Semantic / AI<br/>Header metadata only, GDPR-safe"] --> R[Suggested mappings]
TierConfidenceSource
SavedMappingConfidence.SavedPreviously confirmed mappings stored in database
ExactMappingConfidence.ExactCase-insensitive match on property name, display name, or aliases
FuzzyMappingConfidence.FuzzyLevenshtein similarity above FuzzyMatchThreshold
SemanticMappingConfidence.SemanticAI-backed service (opt-in, only header metadata sent)
StatusDescription
CreatedFile uploaded, job created
PreviewedHeaders extracted, preview and mapping suggestions generated
MappedColumn mappings confirmed by the user
ExecutingImport running (background handler)
CompletedAll rows imported successfully
PartiallyCompletedSome rows failed, others succeeded
FailedImport failed entirely
CancelledCancelled by the user (only from Created, Previewed, or Mapped)

State transitions are guarded — calling a transition from an invalid state throws InvalidOperationException. The valid transition graph is:

Created → Previewed → Mapped → Executing → Completed / PartiallyCompleted / Failed
Created / Previewed / Mapped → Cancelled
new ImportExecutionOptions
{
BatchSize = 500, // Rows per SaveChanges batch
DryRun = true, // Full pipeline with transaction rollback
ErrorBehavior = ImportErrorBehavior.SkipErrors,
}
Error behaviorDescription
FailFastStop immediately on the first error
SkipErrorsSkip errored rows, continue processing (default)
CollectAllProcess all rows, collect all errors without stopping

After an import with SkipErrors or CollectAll, a downloadable CSV correction file is generated. It contains only the failed rows with an additional error message column. Users can fix the rows and re-upload the corrected file.

flowchart LR
    A[Request export] --> B{Row count?}
    B -- "threshold or less" --> C[Synchronous export]
    B -- ">threshold" --> D[Background job]
    C --> E[Query data source]
    D --> E
    E --> F[Project fields]
    F --> G[Write CSV / Excel]
    G --> H[Store blob]
    H --> I[Download link]

Each entity requires an ExportDefinition<T> with a field whitelist. Only declared fields can appear in the output:

public sealed class PatientExportDefinition : ExportDefinition<Patient>
{
public override string Name => "Acme.PatientExport";
public override string? QueryDefinitionName => "Acme.Patients";
protected override void Configure(ExportDefinitionBuilder<Patient> builder)
{
builder
.IncludeBusinessKey()
.Field(p => p.LastName, f => f.Header("Last name"))
.Field(p => p.FirstName, f => f.Header("First name"))
.Field(p => p.Email)
.Field(p => p.BirthDate, f => f
.Header("Date of birth")
.Format("dd/MM/yyyy"))
.Field(p => p.Company, c => c.Name, f => f.Header("Company"));
}
}

Field configuration options:

MethodDescription
.Header(string)Column header name in the exported file
.Format(string)Display format (e.g. "dd/MM/yyyy", "#,##0.00")
.Order(int)Column order (lower values first)

Definition-level options:

MethodDescription
.IncludeId()Include entity Id column for roundtrip import compatibility
.IncludeBusinessKey()Include business key columns from the matching import definition
.IncludeMetadata()Append mapped extra properties (from MapProperty<T>()) as additional export fields at runtime

Navigation fields use a two-argument Field() overload for dot-notation traversal. The developer must ensure the corresponding Include() is present in the IExportDataSource<T> implementation.

When no explicit ExportDefinition<T> is registered for an entity, the system auto-generates one by introspecting the entity’s public properties. This provides immediate export coverage for all entities mapped in a DbContext — no code required.

Auto-generated definitions use the naming convention Auto.{EntityTypeName} (e.g. Auto.Tenant, Auto.BlobDescriptor) and are discoverable via GET /metadata/definitions alongside explicit definitions.

Property filtering rules — the fallback reuses existing security attributes (no dedicated [ExportIgnore] attribute):

ConditionActionRationale
[SensitiveData] (any level, any mode)ExcludedGDPR Art. 25 — all PII excluded by default
[AuditIgnore] on propertyExcludedIf not audited, not exported
[Encrypted(KeyIsolation = true)]ExcludedCrypto-shredded data
Collection properties (IEnumerable<T>)ExcludedNavigation collections
byte[], JsonDocument, JsonElementExcludedBinary/JSON blobs
ConcurrencyStamp, SecurityStampExcludedInfrastructure properties

The API response includes an IsAutoGenerated boolean so the admin UI can display a visual indicator (e.g. “default export — customize via ExportDefinition”).

GET /metadata/definitions — response excerpt
[
{
"name": "Acme.PatientExport",
"entityType": "Patient",
"supportedFormats": ["xlsx", "csv"],
"isAutoGenerated": false
},
{
"name": "Auto.Tenant",
"entityType": "Tenant",
"supportedFormats": ["xlsx", "csv"],
"isAutoGenerated": true
}
]

Data source fallbackDbContextExportDataSource<T> discovers the correct DbContext at runtime by scanning Granit assemblies for DbContext subclasses. The queryable uses AsNoTracking() for performance, except when the entity has mapped extra properties (IHasMetadata with MapProperty<T>()) — in that case tracking is enabled so shadow property values can be read, with periodic ChangeTracker.Clear() to prevent memory bloat during bulk exports. Navigation properties are NOT auto-included — for navigation fields, use an explicit ExportDefinition<T> with a matching IExportDataSource<T>.

Pre-built definitions (Granit.DataExchange.Definitions)

Section titled “Pre-built definitions (Granit.DataExchange.Definitions)”

The Granit.DataExchange.Definitions package provides curated, security-audited ExportDefinition<T> implementations for 39 framework entities across all modules. Each definition uses a whitelist approach: only explicitly declared fields are exported, with [SensitiveData] fields systematically excluded.

[DependsOn(
typeof(GranitDataExchangeDefinitionsModule),
typeof(GranitDataExchangeEntityFrameworkCoreModule))]
public class AppModule : GranitModule { }

This registers export definitions for entities like Tenant, Invoice, PaymentTransaction, AuditEntry, BlobDescriptor, and 34 others. Applications can override any definition by registering their own ExportDefinition<T> for the same entity type — explicit registrations always take precedence.

Key design decisions:

  • ISO 8601 dates — all DateTimeOffset fields use "O" format (roundtrip, preserves offset)
  • Money formatting — all decimal financial fields use "#,##0.00"
  • Extra propertiesDynamicReferenceDataEntity uses .IncludeMetadata() to export mapped extra properties (from MapProperty<T>()) as regular columns
  • Excluded entitiesApiKeyEntry, GranitOpenIddictToken, SigningKey, and other security-sensitive entities are intentionally excluded (see issue #996 for the full list)
  • Internal entitiesAIWorkspaceEntity, AIUsageRecordEntity, TenantFeatureOverride are internal and use the reflection-based fallback within their own assemblies

Presets are named field selections that users can save and reuse. They are stored in the database via IExportPresetReader / IExportPresetWriter. The REST API exposes CRUD operations under /metadata/presets/.

StatusDescription
QueuedJob created and queued for background execution
ExportingExport currently being generated
CompletedFile available for download
FailedExport failed

State transitions are guarded: Queued → Exporting → Completed / Failed.

When QueryDefinitionName is set on an export definition, the export pipeline delegates filtering and sorting to IQueryEngine<T> from Granit.QueryEngine. This reuses the same whitelist-based filtering pipeline as the grid view — the user’s active filters are applied to the export.

FormatParser (import)Writer (export)Package
CSVSep (SIMD-accelerated)Semicolon separator (EU locale)Granit.DataExchange.Csv
Excel (.xlsx, .xls)Sylvan.Data.Excel (streaming)ClosedXMLGranit.DataExchange.Excel

All endpoints require authorization. Import endpoints use the DataExchange.Imports.Execute permission, export endpoints use DataExchange.Exports.Execute. Metadata endpoints require authentication (any authenticated user).

MethodPathDescription
GET/jobsList import jobs
POST/Upload file (creates import job)
POST/{jobId}/previewExtract headers and generate mapping suggestions
PUT/{jobId}/mappingsConfirm column mappings
POST/{jobId}/executeExecute the import
POST/{jobId}/dry-runFull pipeline with transaction rollback
GET/{jobId}Get import job status
DELETE/{jobId}Cancel import job
GET/{jobId}/reportGet import report (success/error counts, row details)
GET/{jobId}/correction-fileDownload CSV with failed rows and error messages
MethodPathDescription
GET/export/jobsList export jobs
POST/export/jobsCreate and execute export
GET/export/jobs/{id}Get export job status
GET/export/jobs/{id}/downloadDownload exported file
MethodPathDescription
GET/metadata/definitionsList registered export definitions
GET/metadata/definitions/{name}/fieldsList available fields for a definition
GET/metadata/presets/{definitionName}List saved presets for a definition
POST/metadata/presetsSave a field selection preset
DELETE/metadata/presets/{definitionName}/{presetName}Delete a preset

Granit.DataExchange.EntityFrameworkCore provides:

  • DataExchangeDbContext with entities for import jobs, export jobs, saved mappings, external ID mappings, and export presets. All entities implement IMultiTenant for automatic tenant query filtering via ApplyGranitConventions.
  • EfImportExecutor — batched INSERT/UPDATE executor with SaveChanges per batch.
  • Identity resolvers: BusinessKeyResolver, CompositeKeyResolver — query the database to determine whether each row is an INSERT or UPDATE.
EntityPurpose
ImportJobEntityTracks import job lifecycle and metadata
ExportJobEntityTracks export job lifecycle and file location
SavedMappingEntityPersists confirmed column mappings for reuse (Tier 1)
ExternalIdMappingEntityMaps external identifiers to internal entity IDs
ExportPresetEntityNamed field selection presets

Without Wolverine, import and export commands dispatch via bounded in-memory Channel<T> (capacity: 100, back-pressure via BoundedChannelFullMode.Wait) — messages are lost on crash. Adding Granit.DataExchange.Wolverine replaces both dispatchers with Wolverine’s IMessageBus for durable outbox-backed execution:

ServiceWithout WolverineWith Wolverine
IImportCommandDispatcherChannelImportCommandDispatcherWolverineImportCommandDispatcher
IExportCommandDispatcherChannelExportCommandDispatcherWolverineExportCommandDispatcher
IDataExchangeEventPublisherNo-opWolverineDataExchangeEventPublisher
CategoryKey typesPackage
ModuleGranitDataExchangeModule, GranitDataExchangeBlobStorageModule, GranitDataExchangeCsvModule, GranitDataExchangeExcelModule, GranitDataExchangeEntityFrameworkCoreModule, GranitDataExchangeEndpointsModule, GranitDataExchangeWolverineModule---
Import pipelineIImportOrchestrator, IMappingSuggestionService, IFileParser, IDataMapper, IRowValidator, IImportExecutorGranit.DataExchange
Import definitionImportDefinition<T>, ImportDefinitionBuilder<T>, PropertyMappingBuilderGranit.DataExchange
Import identityIRecordIdentityResolver, RecordIdentity, RecordOperationGranit.DataExchange
Import reportingImportReport, ImportProgress, ICorrectionFileGeneratorGranit.DataExchange
Export pipelineIExportOrchestrator, IExportWriter, IExportDataSource<T>Granit.DataExchange
Export definitionExportDefinition<T>, ExportDefinitionBuilder<T>, ExportFieldBuilderGranit.DataExchange
Export fallbackIExportDefinitionProvider, IAutoExportDefinitionSource, ExportPropertyFilterGranit.DataExchange
Export extra propsIExtraExportFieldProvider, IExportExtraValueResolverGranit.DataExchange
Export presetsIExportPresetReader, IExportPresetWriterGranit.DataExchange
Pre-built defsGranitDataExchangeDefinitionsModule, AddGranitExportDefinitions()Granit.DataExchange.Definitions
MappingMappingConfidence, ImportColumnMapping, ISemanticMappingServiceGranit.DataExchange
File storageIDataExchangeFileProvider, DataExchangeBlobStorageOptionsGranit.DataExchange, Granit.DataExchange.BlobStorage
OptionsImportOptions, ExportOptions, ImportExecutionOptionsGranit.DataExchange
PermissionsDataExchangePermissions.Imports.{Read,Execute}, DataExchangePermissions.Exports.{Read,Execute}Granit.DataExchange.Endpoints
ExtensionsAddImportDefinition<T, TDef>(), AddExportDefinition<T, TDef>(), AddSemanticMappingService<T>(), MapGranitDataExchange()---

The DataExchange module includes several hardening measures:

  • Multi-tenancy isolation — all entities (ImportJob, ExportJob, SavedMappingEntity, ExternalIdMappingEntity, ExportPresetEntity) implement IMultiTenant, activating automatic tenant query filters via ApplyGranitConventions.
  • Formula injection protection — CSV and Excel writers neutralize cell values starting with =, +, -, @, \t, or \r to prevent spreadsheet formula injection (CWE-1236).
  • Upload validation — filenames are sanitized via Path.GetFileName() to prevent path traversal. File extensions are cross-validated against allowed MIME types.
  • State machine guardsImportJob and ExportJob enforce valid state transitions. Calling a transition from an invalid state throws InvalidOperationException.
  • Error message sanitization — exception messages stored in the database and published via integration events are truncated to 500 characters to prevent information disclosure.
  • Bounded channels — in-memory command dispatch uses bounded channels (capacity 100) with back-pressure to prevent resource exhaustion.
  • AI output validation — LLM mapping suggestions are validated against the known property whitelist and source headers. Confidence scores are clamped to [0.0, 1.0]. Preview row cell values and target field metadata are sanitized before prompt injection.
  • AI options validationDataExchangeAIOptions are validated at startup (ValidateOnStart) to catch misconfigurations (invalid score ranges, zero timeouts).