DEV Community

Cover image for DocumentIO — Building a Unified API for 7 Document Formats in C#
Hoang Nguyen
Hoang Nguyen

Posted on Originally published at vnmlstudio.runasp.net

DocumentIO — Building a Unified API for 7 Document Formats in C#

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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
    };
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)