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:

  1. Unified API — one DocumentIOFacade for all formats
  2. Auto format detection — consumer passes a path, format inferred
  3. Error taxonomy — every library throws different exceptions; normalize into ImportResult
  4. Easy to extend — new format = 1 adapter class, no facade changes
  5. 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:

  1. 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).
  2. Sniffing adds latency. Every import would read the first 4 bytes → extra I/O.
  3. 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:

  1. Facade pattern saves you as formats grow. 3 formats, fine. 7 formats without a facade → nightmare.
  2. Error taxonomy matters more than features. Consumers care about "can this file be read?" not "which library failed."
  3. 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.