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:
internal/converter/options.go— Format constants and validation switch statementinternal/converter/hybrid_generator.go— Two parallel format dispatch switches inGenerate()andGenerateToWriter()cmd/convert.go— Four separate locations: format constants,normalizeFormat()function,validateConvertFlags()list, and file extension switchcmd/shared_flags.go— HardcodedValidFormatsfor shell tab-completioninternal/config/validation.go— HardcodedValidFormatsvariableinternal/processor/processor.go—Transform()switch with incomplete format coverage
Fragmentation Symptoms#
This fragmentation manifested in several ways:
- Alias inconsistency: Aliases were defined in three separate places (
hybrid_generator.goinline switch cases,cmd/convert.goconstants, andnormalizeFormat()) - Incomplete coverage:
processor.Transform()only handled 3 of 5 formats (json, yaml, markdown), silently omitting text and html - Maintenance burden: No single source of truth meant format lists could drift out of sync
- High change friction: Adding a format required 8-10 manual edits across the codebase
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
handleris nil,formatis 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
formatDescriptionsmap (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#
| File | Role | Lines |
|---|---|---|
internal/converter/registry.go | FormatRegistry struct, FormatHandler interface, DefaultRegistry singleton, all 5 handler implementations | 1-286 |
internal/converter/registry_test.go | 76 test cases covering Get, Canonical, Register, ValidFormats, Extensions, handler dispatch; 100% coverage | Full file |
internal/converter/hybrid_generator.go | HybridGenerator, handlerForFormat() registry lookup, Generator/StreamingGenerator interfaces | 125-218 |
internal/converter/options.go | Options struct, format constants, Validate() method | 12-35 |
internal/converter/errors.go | ErrUnsupportedFormat sentinel error | 8 |
internal/converter/plaintext.go | StripMarkdownFormatting() for text format (goldmark → HTML → html2text pipeline) | 52-86 |
internal/converter/html.go | RenderMarkdownToHTML() for HTML format (goldmark with GitHub alert transformation) | 169-178 |
internal/converter/enrichment.go | prepareForExport() for JSON/YAML enrichment | 38-73 |
cmd/convert.go | Convert command; uses registry for format validation and file extension lookup | - |
cmd/shared_flags.go | Shell completion and --format flag; derives valid formats from registry | - |
internal/processor/processor.go | Transform() uses DefaultRegistry.Canonical() for alias resolution | - |
internal/config/validation.go | ValidFormats derived from registry | - |
Design Rationale#
| Concern | Solution |
|---|---|
| Scattered format lists | Single DefaultRegistry singleton as authoritative source |
| Format validation in multiple places | DefaultRegistry.Get() as authoritative validator |
| Alias inconsistency | DefaultRegistry.Canonical() used everywhere for resolution |
| Adding new formats requires touching 8+ files | Register one FormatHandler in newDefaultRegistry() |
| Shell completion and file extensions diverging | Both derived from registry at runtime |
| Fail-fast on misconfiguration | Register() panics on duplicate/nil at init time (database/sql pattern) |
| Thread safety for concurrent access | sync.RWMutex enables lock-free reads after initialization |
| Code reuse across formats | Text and HTML formats post-process markdown, treating it as canonical representation |
Related Topics#
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#
- Report Generation Architecture — Detailed architecture documentation
- Refactoring Patterns — Converter pattern fragmentation analysis
- Technical Deep-Dive: Architecture, Internals & Roadmap — Implementation context
- Multi-Format Export — Format handler details
- Architecture Overview — System-level context
- Architecture Review Findings — Consolidation effort tracking
Code#
- Main repository
internal/converter/registry.go— Primary implementationinternal/converter/registry_test.go— Comprehensive test suite
Issues#
- Issue #325: Format Registry Pattern — Problem statement and proposed solution
- Issue #302: DeviceParser Registry — Parallel pattern for input parsers