Documents
Format Registry Pattern
Format Registry Pattern
Type
Topic
Status
Published
Created
Mar 20, 2026
Updated
Mar 20, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

Format Registry Pattern - Knowledge Base Article#

Lead Section#

The Format Registry Pattern is an architectural pattern used in the opnDossier project for centralized format dispatch management. It provides a single source of truth for supported output formats by implementing a strategy registry pattern following Go's database/sql driver registration model.

The pattern replaces scattered format dispatch logic across multiple locations with a unified FormatRegistry that manages format validation, aliases, file extensions, and generation routing. Each output format implements the FormatHandler interface, which defines methods for metadata (file extension, aliases) and generation logic (string output and streaming output).

The DefaultRegistry singleton is pre-populated with five built-in format handlers (Markdown, JSON, YAML, Text, HTML) at initialization time. Adding a new format requires only implementing the FormatHandler interface and registering it in the newDefaultRegistry() function—all validation, shell completions, and dispatch logic automatically support the new format. The pattern is documented in AGENTS.md §5.9b as an authoritative implementation guide.


Problem Solved#

Scattered Format Routing#

Before the Format Registry Pattern, adding a new export format required coordinated changes across 6 distinct locations in 4 files:

  1. internal/converter/options.go — Format constants and validation switch statement
  2. internal/converter/hybrid_generator.go — Two parallel format dispatch switches in Generate() and GenerateToWriter()
  3. cmd/convert.go — Four separate locations: format constants, normalizeFormat() function, validateConvertFlags() list, and file extension switch
  4. cmd/shared_flags.go — Hardcoded ValidFormats for shell tab-completion
  5. internal/config/validation.go — Hardcoded ValidFormats variable
  6. internal/processor/processor.goTransform() switch with incomplete format coverage

Fragmentation Symptoms#

This fragmentation manifested in several ways:


Architecture and Design#

FormatRegistry Structure#

The FormatRegistry struct uses two internal maps for format resolution:

type FormatRegistry struct {
    mu sync.RWMutex
    handlers map[string]FormatHandler // canonical name → handler
    aliases map[string]string // alias → canonical name
}

The sync.RWMutex enables thread-safe concurrent reads (format lookups during report generation) while protecting writes (handler registration at initialization).

FormatHandler Interface#

Each format implements the FormatHandler interface:

type FormatHandler interface {
    FileExtension() string
    Aliases() []string
    Generate(g *HybridGenerator, data *common.CommonDevice, opts Options) (string, error)
    GenerateToWriter(g *HybridGenerator, w io.Writer, data *common.CommonDevice, opts Options) error
}

Design note: The interface accepts *HybridGenerator (concrete type) rather than an interface because this is a dispatch table pattern, not a strategy pattern. All handlers are internal to the converter package and delegate to private methods on HybridGenerator.

DefaultRegistry Singleton#

The DefaultRegistry singleton is initialized at package load time:

var DefaultRegistry = newDefaultRegistry()

func newDefaultRegistry() *FormatRegistry {
    r := NewFormatRegistry()
    r.Register(string(FormatMarkdown), &markdownHandler{})
    r.Register(string(FormatJSON), &jsonHandler{})
    r.Register(string(FormatYAML), &yamlHandler{})
    r.Register(string(FormatText), &textHandler{})
    r.Register(string(FormatHTML), &htmlHandler{})
    return r
}

All five format constants are defined in options.go:

const (
    FormatMarkdown Format = "markdown"
    FormatJSON Format = "json"
    FormatYAML Format = "yaml"
    FormatText Format = "text"
    FormatHTML Format = "html"
)

Database/SQL Driver Pattern#

The registry follows the database/sql driver pattern: the Register() method panics on duplicate format names, aliases, or nil handlers. This fail-fast behavior catches configuration errors at initialization time:

func (r *FormatRegistry) Register(format string, handler FormatHandler) {
    if handler == nil {
        panic(fmt.Sprintf("converter: nil handler for format %q", format))
    }
    // ... validation logic that panics on duplicate names or aliases ...
}

Format Handler Implementations#

All five handlers are defined in registry.go (lines 202-286) and follow a uniform delegation pattern:

1. Markdown Handler#

The markdownHandler provides the native output format:

type markdownHandler struct{}

func (h *markdownHandler) FileExtension() string { return ".md" }
func (h *markdownHandler) Aliases() []string { return []string{"md"} }
func (h *markdownHandler) Generate(g *HybridGenerator, data *common.CommonDevice, opts Options) (string, error) {
    return g.generateMarkdown(data, opts)
}

Delegates to g.generateMarkdown(), which calls the programmatic builder's BuildStandardReport() or BuildComprehensiveReport() and appends BuildAuditSection() when compliance data is present.

2. JSON Handler#

The jsonHandler provides structured data export:

type jsonHandler struct{}

func (h *jsonHandler) FileExtension() string { return ".json" }
func (h *jsonHandler) Aliases() []string { return nil }
func (h *jsonHandler) Generate(g *HybridGenerator, data *common.CommonDevice, opts Options) (string, error) {
    return g.generateJSON(data, opts)
}

Delegates to g.generateJSON(), which enriches the device via prepareForExport() (populating Statistics, Analysis, SecurityAssessment, PerformanceMetrics) and serializes with json.MarshalIndent().

3. YAML Handler#

The yamlHandler provides structured data export with alias support:

type yamlHandler struct{}

func (h *yamlHandler) FileExtension() string { return ".yaml" }
func (h *yamlHandler) Aliases() []string { return []string{"yml"} }
func (h *yamlHandler) Generate(g *HybridGenerator, data *common.CommonDevice, opts Options) (string, error) {
    return g.generateYAML(data, opts)
}

Delegates to g.generateYAML(), which uses prepareForExport() and serializes with yaml.Marshal() from gopkg.in/yaml.v3.

4. Text Handler#

The textHandler provides plain text output via post-processing:

type textHandler struct{}

func (h *textHandler) FileExtension() string { return ".txt" }
func (h *textHandler) Aliases() []string { return []string{"txt"} }
func (h *textHandler) Generate(g *HybridGenerator, data *common.CommonDevice, opts Options) (string, error) {
    return g.generatePlainText(data, opts)
}

Delegates to g.generatePlainText(), which generates markdown first then calls StripMarkdownFormatting(). This function uses a goldmark → HTML → html2text pipeline with custom placeholder-based handling for tables and alerts.

5. HTML Handler#

The htmlHandler provides web-ready output:

type htmlHandler struct{}

func (h *htmlHandler) FileExtension() string { return ".html" }
func (h *htmlHandler) Aliases() []string { return []string{"htm"} }
func (h *htmlHandler) Generate(g *HybridGenerator, data *common.CommonDevice, opts Options) (string, error) {
    return g.generateHTML(data, opts)
}

Delegates to g.generateHTML(), which generates markdown first then calls RenderMarkdownToHTML(). This converts markdown via goldmark, transforms GitHub-style alert blockquotes into styled div elements, and wraps the result in a self-contained HTML document with embedded CSS.

Format Transformation Architecture#

Two formats use multi-stage transformation pipelines:

  • Text: markdown → goldmark → HTML → html2text with custom table/alert handling
  • HTML: markdown → goldmark with GitHub alert transformation and CSS injection

This architecture maximizes code reuse by treating markdown as the canonical representation.


Registry Methods#

Register()#

Register(format, handler) adds a new format handler with fail-fast validation:

  • Panics if handler is nil, format is empty, format is already registered, or any alias conflicts with existing formats/aliases
  • Normalizes format and alias keys to lowercase for case-insensitive lookup
  • Atomically commits both handler and alias registrations

Get()#

Get(format) retrieves a handler by canonical name or alias:

func (r *FormatRegistry) Get(format string) (FormatHandler, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    key := strings.TrimSpace(strings.ToLower(format))

    if h, ok := r.handlers[key]; ok {
        return h, nil
    }

    if canonical, ok := r.aliases[key]; ok {
        return r.handlers[canonical], nil
    }

    return nil, fmt.Errorf("%w: %s", ErrUnsupportedFormat, format)
}

Returns ErrUnsupportedFormat for unknown formats. Used by Format.Validate() for option validation.

Canonical()#

Canonical(format) resolves aliases to canonical names:

  • "md""markdown"
  • "yml""yaml"
  • "txt""text"
  • "htm""html"

Returns (canonicalName, true) if the format exists (either canonical or alias), or (lowercasedInput, false) otherwise.

ValidFormats()#

ValidFormats() returns a sorted slice of canonical format names (no aliases):

func (r *FormatRegistry) ValidFormats() []string {
    r.mu.RLock()
    defer r.mu.RUnlock()

    formats := make([]string, 0, len(r.handlers))
    for name := range r.handlers {
        formats = append(formats, name)
    }

    slices.Sort(formats)
    return formats
}

Returns: ["html", "json", "markdown", "text", "yaml"]

ValidFormatsWithAliases()#

ValidFormatsWithAliases() returns a sorted slice of all accepted format strings (canonical + aliases):

Returns: ["htm", "html", "json", "md", "markdown", "text", "txt", "yaml", "yml"]

Used by shell completion logic in cmd/shared_flags.go.

Extensions()#

Extensions() returns a map of canonical format name → file extension:

func (r *FormatRegistry) Extensions() map[string]string {
    r.mu.RLock()
    defer r.mu.RUnlock()

    exts := make(map[string]string, len(r.handlers))
    for name, h := range r.handlers {
        exts[name] = h.FileExtension()
    }

    return exts
}

Returns: {"markdown": ".md", "json": ".json", "yaml": ".yaml", "text": ".txt", "html": ".html"}


Handler Dispatch#

handlerForFormat() Helper#

The handlerForFormat() helper provides a thin wrapper around DefaultRegistry.Get():

func handlerForFormat(format string) (FormatHandler, error) {
    return DefaultRegistry.Get(format)
}

Integration in HybridGenerator#

Both Generate() and GenerateToWriter() use registry dispatch:

func (g *HybridGenerator) Generate(_ context.Context, data *common.CommonDevice, opts Options) (string, error) {
    if data == nil {
        return "", ErrNilDevice
    }

    if err := opts.Validate(); err != nil {
        return "", fmt.Errorf("invalid options: %w", err)
    }

    handler, err := handlerForFormat(string(opts.Format))
    if err != nil {
        return "", err
    }

    return handler.Generate(g, data, opts)
}

This replaces scattered switch statements with centralized registry lookup.


Integration Points#

CLI Layer (cmd/)#

cmd/convert.go: Uses the registry for:

  • Format validation via DefaultRegistry.Get() instead of switch statements
  • File extension lookup via DefaultRegistry.Extensions() instead of inline switches

cmd/shared_flags.go: Uses the registry for:

  • Shell completion via DefaultRegistry.ValidFormats() (returns alphabetically sorted canonical names)
  • Format descriptions maintained separately in a formatDescriptions map (not part of the handler interface)

Config Layer (internal/config/)#

internal/config/validation.go: Derives ValidFormats from the registry:

var ValidFormats = func() []string {
    return slices.Clone(converter.DefaultRegistry.ValidFormats())
}()

Uses slices.Clone() for immutability.

Processor Layer (internal/processor/processor.go)#

processor.Transform(): Uses DefaultRegistry.Canonical() for alias resolution before dispatching:

  • Text format: Generates markdown then calls exported converter.StripMarkdownFormatting()
  • HTML format: Generates markdown then calls exported converter.RenderMarkdownToHTML()
  • All standard aliases (md, yml, txt, htm) work consistently across converter and processor code paths

Usage Examples#

Adding a New Format#

To register a new output format, implement FormatHandler and register it in newDefaultRegistry():

// 1. Implement the interface
type csvHandler struct{}

func (h *csvHandler) FileExtension() string { return ".csv" }
func (h *csvHandler) Aliases() []string { return []string{"CSV"} }

func (h *csvHandler) Generate(g *HybridGenerator, data *common.CommonDevice, opts Options) (string, error) {
    return g.generateCSV(data, opts) // new private method on HybridGenerator
}

func (h *csvHandler) GenerateToWriter(g *HybridGenerator, w io.Writer, data *common.CommonDevice, opts Options) error {
    csv := csv.NewWriter(w)
    // ... write CSV rows ...
    return csv.Error()
}

// 2. Register in newDefaultRegistry()
func newDefaultRegistry() *FormatRegistry {
    r := NewFormatRegistry()
    r.Register(string(FormatMarkdown), &markdownHandler{})
    r.Register(string(FormatJSON), &jsonHandler{})
    r.Register(string(FormatYAML), &yamlHandler{})
    r.Register(string(FormatText), &textHandler{})
    r.Register(string(FormatHTML), &htmlHandler{})
    r.Register("csv", &csvHandler{}) // Add new handler
    return r
}

All validation, shell completion, file extension handling, and generation routing automatically support the new format with no changes to caller code.

Format Lookup and Validation#

// Case-insensitive lookup with alias resolution
handler, err := converter.DefaultRegistry.Get("MD") // Returns markdownHandler
if err != nil {
    // Handle ErrUnsupportedFormat
}

// Resolve alias to canonical name
canonical, ok := converter.DefaultRegistry.Canonical("yml") // Returns ("yaml", true)

// Get all valid format strings for CLI completion
formats := converter.DefaultRegistry.ValidFormatsWithAliases()
// Returns: ["htm", "html", "json", "md", "markdown", "text", "txt", "yaml", "yml"]

// Get file extension for a format
exts := converter.DefaultRegistry.Extensions()
ext := exts["markdown"] // Returns ".md"

Relevant Code Files#

FileRoleLines
internal/converter/registry.goFormatRegistry struct, FormatHandler interface, DefaultRegistry singleton, all 5 handler implementations1-286
internal/converter/registry_test.go76 test cases covering Get, Canonical, Register, ValidFormats, Extensions, handler dispatch; 100% coverageFull file
internal/converter/hybrid_generator.goHybridGenerator, handlerForFormat() registry lookup, Generator/StreamingGenerator interfaces125-218
internal/converter/options.goOptions struct, format constants, Validate() method12-35
internal/converter/errors.goErrUnsupportedFormat sentinel error8
internal/converter/plaintext.goStripMarkdownFormatting() for text format (goldmark → HTML → html2text pipeline)52-86
internal/converter/html.goRenderMarkdownToHTML() for HTML format (goldmark with GitHub alert transformation)169-178
internal/converter/enrichment.goprepareForExport() for JSON/YAML enrichment38-73
cmd/convert.goConvert command; uses registry for format validation and file extension lookup-
cmd/shared_flags.goShell completion and --format flag; derives valid formats from registry-
internal/processor/processor.goTransform() uses DefaultRegistry.Canonical() for alias resolution-
internal/config/validation.goValidFormats derived from registry-

Design Rationale#

ConcernSolution
Scattered format listsSingle DefaultRegistry singleton as authoritative source
Format validation in multiple placesDefaultRegistry.Get() as authoritative validator
Alias inconsistencyDefaultRegistry.Canonical() used everywhere for resolution
Adding new formats requires touching 8+ filesRegister one FormatHandler in newDefaultRegistry()
Shell completion and file extensions divergingBoth derived from registry at runtime
Fail-fast on misconfigurationRegister() panics on duplicate/nil at init time (database/sql pattern)
Thread safety for concurrent accesssync.RWMutex enables lock-free reads after initialization
Code reuse across formatsText and HTML formats post-process markdown, treating it as canonical representation

DeviceParser Registry (Issue #302)#

The Format Registry Pattern is the output format counterpart to the DeviceParser registry pattern proposed in issue #302. Both follow Go's database/sql driver registration model and are planned for the v1.3.0 milestone (phase 2, steps 8-9).

AGENTS.md Documentation#

AGENTS.md §5.9b contains authoritative implementation guidance for the FormatRegistry pattern. §5.9a documents consumer-local interface narrowing patterns.

Multi-Stage Format Transformation#

The text and HTML format handlers demonstrate a multi-stage transformation architecture:

  • Text: markdown → goldmark → HTML → html2text (with custom table/alert handling)
  • HTML: markdown → goldmark (with GitHub alert transformation and CSS injection)

Converter Pattern Consolidation#

The Format Registry Pattern is part of a broader converter pattern consolidation effort spanning multiple PRs:

  • PR #400: Audit rendering to builder pattern
  • PR #409: Shared analysis package
  • PR #431: ReportBuilder interface segregation
  • Related: Format Registry Pattern

References#

Documentation#

Code#

Issues#