Readers & Writers
NBatch ships with built-in readers and writers for common data sources. You can also implement your own by using the IReader<T> and IWriter<T> interfaces.
Built-in Components
| Component | Package | Direction | Description |
|---|---|---|---|
CsvReader<T> | NBatch | Read | Delimited text files (CSV, TSV, pipe) |
DbReader<T> | NBatch.EntityFrameworkCore | Read | Any EF Core DbContext with pagination |
DbWriter<T> | NBatch.EntityFrameworkCore | Write | Any EF Core DbContext |
FlatFileItemWriter<T> | NBatch | Write | Serializes objects to delimited text |
CsvReader<T>
Reads items from a delimited text file. Automatically parses headers from the first row.
Fields may be quoted (RFC 4180): a quoted field can contain the delimiter, and "" inside a quoted field is an escaped literal quote. Embedded newlines inside quoted fields are not supported — the reader is line-based. Values are trimmed, and all parsing uses the invariant culture.
var reader = new CsvReader<Product>("products.csv", row => new Product
{
Name = row.GetString("Name"),
Price = row.GetDecimal("Price")
});
Automatic mapping
Skip the mapping function entirely and let headers bind to properties:
var reader = new CsvReader<Product>("products.csv");
- Header names match public settable properties case-insensitively.
- Supported types:
string,int,long,decimal,double,float,bool,DateTime,DateTimeOffset,Guid, enums (case-insensitive), andNullable<>of each — an empty value becomesnullfor nullable properties. - Unmatched headers are ignored; unmatched (or unsupported-type) properties keep their defaults.
Tneeds a public parameterless constructor — positional records don’t have one, so use the map-function overload for those (a clear error at construction points this out).- Auto-mapping is reflection-based, so it is not trimming/AOT-safe.
Options
Custom delimiter – override the default comma:
var reader = new CsvReader<Product>("data.tsv", mapFn)
.WithDelimiter('\t');
Explicit headers – provide column names instead of reading from the first row:
var reader = new CsvReader<Product>("data.csv", mapFn)
.WithHeaders("Name", "Description", "Price");
CsvRow API
The mapping function receives a CsvRow with typed accessor methods. Each method is available with both name and index overloads:
| Method | Return Type | Example |
|---|---|---|
GetString("column") | string | row.GetString("Name") or row.GetString(0) |
GetInt("column") | int | row.GetInt("Quantity") or row.GetInt(1) |
GetLong("column") | long | row.GetLong("Id") or row.GetLong(0) |
GetDecimal("column") | decimal | row.GetDecimal("Price") or row.GetDecimal(2) |
GetDouble("column") | double | row.GetDouble("Weight") or row.GetDouble(3) |
GetBool("column") | bool | row.GetBool("Active") or row.GetBool(4) |
GetDateTime("column") | DateTime | row.GetDateTime("CreatedOn") or row.GetDateTime(5) |
GetGuid("column") | Guid | row.GetGuid("Id") or row.GetGuid(6) |
Every accessor also has a nullable *OrNull variant (GetIntOrNull, GetStringOrNull, GetDateTimeOrNull, …) that returns null when the column is missing or the value is empty — a non-empty unparseable value still throws.
Error behavior
- A line that fails to parse or map throws a
FlatFileParseExceptionwhoseLineNumberis the 1-based physical line in the file, with the original error asInnerException. Skip policies for either the wrapper or the inner type (e.g.FormatException) match it. - Blank (whitespace-only) lines are ignored wherever they appear — including runs of blank lines longer than the chunk size — and never count toward chunk positions.
- Duplicate header names are rejected with a clear error.
- A missing file throws
FileNotFoundException— file I/O errors are never wrapped as parse errors, so they are not accidentally skippable.
DbReader<T>
Reads entities from any EF Core DbContext in paginated chunks. Provider-agnostic – works with SQL Server, PostgreSQL, SQLite, etc.
Lives in the
NBatch.EntityFrameworkCorepackage (namespaceNBatch.Readers.DbReader).
var reader = new DbReader<Product>(dbContext, q => q.OrderBy(p => p.Id));
The queryBuilder parameter applies ordering (and optional filtering) to the queryable. A deterministic OrderBy clause is required and enforced: pagination — and the item-level re-reads performed when a chunk fails under a skip policy — rely on stable row positions. An unordered SQL query may return rows in any order, which would silently corrupt chunking and restarts, so the first read throws InvalidOperationException if no ordering was applied.
// With filtering
var reader = new DbReader<Order>(dbContext, q => q
.Where(o => o.Status == "pending")
.OrderBy(o => o.CreatedAt));
DbWriter<T>
Writes entities to any EF Core DbContext. Calls AddRange followed by SaveChangesAsync, then detaches the written entities so the change tracker stays flat on long-running jobs (entities you track separately on a shared context are untouched).
Lives in the
NBatch.EntityFrameworkCorepackage (namespaceNBatch.Writers.DbWriter).
var writer = new DbWriter<Product>(dbContext);
FlatFileItemWriter<T>
Serializes objects to a delimited text file using reflection-based property serialization.
var writer = new FlatFileItemWriter<Product>("output.csv");
Custom separator
var writer = new FlatFileItemWriter<Product>("output.tsv")
.WithDelimiter('\t');
Default token: , (comma)
Output format
- Values are formatted with the invariant culture, matching
CsvReader’s parsing — the same file round-trips on any machine locale. - Fields containing the delimiter, a quote, or a line break are quoted RFC 4180-style (embedded quotes doubled), so the output stays parseable.
- The writer appends to the destination file. That keeps restart-from-failure safe (previously written output survives a resume), but it also means re-running a completed job appends a second copy — use a per-run file name (e.g. timestamped) for jobs that run repeatedly.
Custom Readers & Writers
IReader<T>
public interface IReader<TItem>
{
Task<IEnumerable<TItem>> ReadAsync(
long startIndex,
int chunkSize,
CancellationToken cancellationToken = default);
}
Implement this to read from any source – REST APIs, message queues, cloud storage, etc.
Contract:
ReadAsyncmust honorstartIndex(random access). When a chunk fails under a skip policy, NBatch re-reads the chunk range one item at a time (ReadAsync(index, 1)) to isolate the failing positions — a forward-only reader that ignoresstartIndexwill misbehave there and on restarts.Two more rules follow from position-based tracking:
- Stable positions — the same
startIndexmust map to the same item on every call, including across process restarts. For databases this means a deterministicORDER BY; for growing sources it means restarts assume the already-processed prefix hasn’t shifted.- Full chunks — return exactly
chunkSizeitems for every range before the end of the data; a shorter result is only valid for the final chunk, and an empty result means end of data. The engine advances bychunkSizepositions per chunk and fails the step if a reader produces more items after a partial chunk.
public class ApiReader<T> : IReader<T>
{
public async Task<IEnumerable<T>> ReadAsync(
long startIndex, int chunkSize, CancellationToken ct)
{
// Fetch a page of items from your API
return await httpClient.GetFromJsonAsync<List<T>>(
$"/api/items?skip={startIndex}&take={chunkSize}", ct);
}
}
IWriter<T>
public interface IWriter<TItem>
{
Task WriteAsync(
IEnumerable<TItem> items,
CancellationToken cancellationToken = default);
}
Lambda Writers
You can skip implementing IWriter<T> and use a lambda directly:
// Simple async lambda
.WriteTo(async items =>
{
foreach (var item in items)
Console.WriteLine(item);
})
// With CancellationToken
.WriteTo(async (items, ct) =>
{
await httpClient.PostAsJsonAsync("/api/products", items, ct);
})
Custom Processors
IProcessor<TInput, TOutput>
public interface IProcessor<TInput, TOutput>
{
Task<TOutput> ProcessAsync(
TInput input,
CancellationToken cancellationToken = default);
}
Or use a lambda — synchronous or async:
// Synchronous lambda
.ProcessWith(p => new ProductDto { Name = p.Name.ToUpper(), Price = p.Price })
// Async lambda with CancellationToken
.ProcessWith(async (p, ct) =>
{
var rate = await exchangeService.GetRateAsync(ct);
return new ProductDto { Name = p.Name, Price = p.Price * rate };
})
Next: Skip Policies →