DocumentIO — Building a Unified API for 7 Document Formats in C
This is part of my series on building the VNML Agent Framework — a 100% local agentic AI system in C#. This post covers DocumentIO, the facade that handles import/export for 7 document formats with 3 public methods.
The Problem
I had a simple requirement: import any document → plain text; export text → any document.
The formats I needed:
-
.docx(Word) — for LLM-readable reports -
.xlsx(Excel) — for structured data extraction -
.pdf— for vendor contracts and specs -
.md,.txt— for source code docs -
.csv,.json— for config and datasets
7 formats × 2 directions = 14 use cases.
The naive approach: call each library directly.
// ❌ What I almost did
if (path.EndsWith(".docx")) {
using var doc = WordprocessingDocument.Open(path, false);
foreach (var para in doc.MainDocumentPart.Document.Body.Descendants<Paragraph>())
sb.AppendLine(para.InnerText);
} else if (path.EndsWith(".xlsx")) {
using var wb = new XLWorkbook(path);
// ...50 more lines
} else if (path.EndsWith(".pdf")) {
// ...70 more lines
} // ... ad infinitum
That's 14 different code paths, 14 exception styles (OpenXmlPackageException, PdfReaderException, InvalidOperationException with cryptic messages from ClosedXML), and 14 places to fix whenever a new format arrives.
That's not architecture. That's a maintenance trap.
High-Level Design
DocumentIO takes the Facade pattern to its logical conclusion: consumers never touch underlying libraries.
┌─────────────────────────────────────────┐
│ ChatCompletions · MMO · DocumentProc │
└────────────────────┬────────────────────┘
│ ImportAsync / ExportAsync
▼
┌─────────────────────────────────────────┐
│ DocumentIOFacade │
│ - Format detection (ext → enum) │
│ - Adapter resolution (DI lookup) │
│ - Result normalization (ex → result) │
└────────────────────┬────────────────────┘
│ IDocumentAdapter
▼
┌──────┬──────┬──────┬──────┬──────┬──────┐
│ Docx │ Xlsx │ Pdf │ Md │ Txt │ Csv │
└───┬──┴───┬──┴───┬──┴───┬──┴───┬──┴───┬──┘
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
OpenXml ClosedXml PdfSharp Markdig File
5 design goals, in priority order:
- Unified API — one DocumentIOFacade for all formats
- Auto format detection — consumer passes a path, format inferred
- Error taxonomy — every library throws different exceptions; normalize into ImportResult
- Easy to extend — new format = 1 adapter class, no facade changes
- Testability — each adapter tested in isolation
The entire public API:
public class DocumentIOFacade
{
public Task<ImportResult> ImportAsync(string filePath, CancellationToken ct = default);
public Task<ExportResult> ExportAsync(DocumentFormat format, ExportRequest req, CancellationToken ct = default);
public static DocumentFormat InferFormatFromPath(string path);
}
That's it. Three methods. Everything else is implementation detail.
Detail Design
The Adapter Contract
public interface IDocumentAdapter
{
DocumentFormat Format { get; }
Task<ImportResult> ImportAsync(string filePath, CancellationToken ct);
Task<ExportResult> ExportAsync(ExportRequest request, CancellationToken ct);
}
Why Adapter, not Strategy?
- Strategy = pick different algorithms for the same interface (e.g., 3 sort methods).
- Adapter = convert a library's interface into a common interface.
OpenXml has no ImportAsync method. PdfSharp has no ImportAsync method. Each adapter wraps a completely different API surface into one uniform contract. Classic GoF Adapter.
Simplest Adapter (Markdown)
public class MarkdownAdapter : IDocumentAdapter
{
public DocumentFormat Format => DocumentFormat.Markdown;
public async Task<ImportResult> ImportAsync(string filePath, CancellationToken ct)
{
var text = await File.ReadAllTextAsync(filePath, ct);
return ImportResult.Success(text, wordCount: text.Split(' ').Length);
}
public async Task<ExportResult> ExportAsync(ExportRequest request, CancellationToken ct)
{
await File.WriteAllTextAsync(request.TargetPath, request.Content, ct);
return ExportResult.Success();
}
}
That's 15 lines. New formats can be this small.
Most Complex Adapter (Docx)
public class DocxAdapter : IDocumentAdapter
{
public DocumentFormat Format => DocumentFormat.Docx;
public async Task<ImportResult> ImportAsync(string filePath, CancellationToken ct)
{
try
{
using var doc = WordprocessingDocument.Open(filePath, false);
var body = doc.MainDocumentPart?.Document.Body;
if (body is null)
return ImportResult.Failure("Empty document");
var sb = new StringBuilder();
foreach (var para in body.Descendants<Paragraph>())
sb.AppendLine(para.InnerText);
return ImportResult.Success(sb.ToString());
}
catch (Exception ex)
{
return ImportResult.Failure($"OpenXml error: {ex.Message}");
}
}
// Export: markdown → OOXML paragraphs
// (similar pattern, ~60 lines)
}
The try/catch returning Failure instead of throwing is the key insight: adapters swallow library exceptions and normalize them.
Error Taxonomy — The Most Important Part
Without normalization, consumers would need:
// ❌ Coupling nightmare
try { /* import */ }
catch (OpenXmlPackageException ex) { }
catch (PdfReaderException ex) { }
catch (InvalidOperationException ex) when (ex.Message.Contains("sheet")) { }
// ...10 more exception types
With DocumentIO:
// ✅ One check, done
var result = await documentIO.ImportAsync(path);
if (!result.Success)
logger.LogWarning("Import failed: {Error}", result.ErrorMessage);
public record ImportResult
{
public bool Success { get; init; }
public string? Text { get; init; }
public int WordCount { get; init; }
public string? ErrorMessage { get; init; }
public static ImportResult Success(string text, int wordCount = 0) => new()
{ Success = true, Text = text, WordCount = wordCount };
public static ImportResult Failure(string error) => new()
{ Success = false, ErrorMessage = error };
}
This is Dependency Inversion in practice: high-level module doesn't know which low-level library failed.
Format Detection — Why Not Sniff Magic Bytes?
Many libraries peek at the first 4 bytes to detect the real format. DocumentIO deliberately doesn't:
- Extension is the user's contract. If someone renames report.docx to report.bak, respect their intent (it's a backup, not a Word file).
- Sniffing adds latency. Every import would read the first 4 bytes → extra I/O.
- Extension is 99% accurate. The 1% edge case is the user's problem to fix.
public static DocumentFormat InferFormatFromPath(string path) =>
Path.GetExtension(path).ToLowerInvariant() switch
{
".docx" => DocumentFormat.Docx,
".xlsx" or ".xlsm" => DocumentFormat.Xlsx,
".pdf" => DocumentFormat.Pdf,
".md" or ".markdown" => DocumentFormat.Markdown,
".txt" or ".log" => DocumentFormat.Text,
".csv" or ".tsv" => DocumentFormat.Csv,
".json" => DocumentFormat.Json,
_ => DocumentFormat.Unknown
};
Handles: case-insensitivity, no extension, double extension (.bak wins → Unknown).
Trade-offs
No architecture is free. DocumentIO has 3 drawbacks:
1. Doesn't expose full library features.
OpenXml can read tables, styles, headers, revision history. DocumentIO returns plain text. If you need full features → call OpenXml directly.
2. Small performance overhead.
~1-2ms per call from format detection + DI lookup. For 1000 files, that's ~1-2 seconds. Acceptable for most use cases.
3. Hard to add exotic formats.
.pages, .odt, .epub aren't supported. You write another adapter (~80 lines) or use a library directly.
The Actual Question: Why Build This at All?
Because when you have 7 formats × 2 directions × 3 consumer plugins = 42 integration points, a facade isn't optional — it's the difference between a codebase that scales and one that collapses under its own weight.
Three key takeaways:
- Facade pattern saves you as formats grow. 3 formats, fine. 7 formats without a facade → nightmare.
- Error taxonomy matters more than features. Consumers care about "can this file be read?" not "which library failed."
- Adapter pattern is mandatory when wrapping third-party libs. Library update breaks API? Fix one adapter, not the whole codebase.
What's Next
The next post will benchmark DocumentIO against Aspose.Words with 1000 real files.
Prediction: Aspose is 3× faster but 100× more expensive. Classic trade-off.
Source code: github.com/hoangnc/VNMLAgentFramework
Built in C#, runs 100% local, no cloud dependency. Everything from a 4GB VRAM laptop in a 16m² room without AC in Vietnam.
What architecture decisions have saved you from maintenance hell? Drop a comment — I'm curious how others handle this pattern.
Top comments (0)