Type-safe XML processing in Go: A modern approach with generics and design patterns

Processing XML in Go has traditionally involved repetitive code, runtime type assertions, and complex validation logic. Modern Go applications demand better: type safety, maintainability, and performance. I want to explore how Moov’s wire20022 library demonstrates a comprehensive approach to XML processing using Go generics, embedded structs, factory patterns, and idiomatic interfaces.

I’ll share real-world code that processes ISO 20022 financial messages, handling multiple versions, complex validation, and high-performance requirements while maintaining compile-time type safety.

The challenge: Complex XML processing at scale

ISO 20022 financial messages present several challenges typical of enterprise XML processing:

  • Multiple versions: Each message type supports 10+ schema versions
  • Complex validation: Hundreds of required fields with version-specific rules
  • Performance: High-throughput parsing and generation
  • Type safety: Runtime errors must be caught at compile time
  • Maintainability: Thousands of lines of XML processing code must remain readable

Traditional approaches often result in reflection-heavy code, runtime type assertions, and duplicated validation logic. Let’s see how modern Go can do better.

Foundation: Embedded structs for code reuse

Rather than duplicating common fields across message types, wire20022 uses embedded structs to create composable, reusable components:

// MessageHeader contains common fields found in all ISO 20022 messages
type MessageHeader struct {
    MessageId       string    `json:"messageId"`
    CreatedDateTime time.Time `json:"createdDateTime"`
}

// PaymentCore extends MessageHeader with payment-specific fields
type PaymentCore struct {
    MessageHeader
    NumberOfTransactions  string                      `json:"numberOfTransactions"`
    SettlementMethod      models.SettlementMethodType `json:"settlementMethod"`
    CommonClearingSysCode models.CommonClearingSysCodeType `json:"commonClearingSysCode"`
}

// AgentPair represents the common pattern of agent pairs in messages
type AgentPair struct {
    InstructingAgent models.Agent `json:"instructingAgent"`
    InstructedAgent  models.Agent `json:"instructedAgent"`
}

Message types then compose these abstractions instead of duplicating fields:

// MessageModel uses base abstractions to eliminate duplicate field definitions
type MessageModel struct {
    // Embed common payment fields instead of duplicating them
    base.PaymentCore `json:",inline"`
    
    // Core fields present in all versions
    InstructionId       string                        `json:"instructionId"`
    EndToEndId          string                        `json:"endToEndId"`
    InterBankSettAmount models.CurrencyAndAmount      `json:"interBankSettAmount"`
    InterBankSettDate   fedwire.ISODate               `json:"interBankSettDate"`
    
    // Embed common agent pattern
    base.AgentPair `json:",inline"`
    
    // Version-specific fields (V8+)
    Transaction *TransactionFields `json:"transaction,omitempty"`
}

This approach eliminates hundreds of lines of duplicated field definitions while maintaining clear, readable structure.

Type-safe generics for processing logic

The core innovation is a generic MessageProcessor that handles XML parsing, validation, and document creation with compile-time type safety. Here’s how it transforms XML processing:

// MessageProcessor provides generic message processing capabilities
// using type parameters to maintain type safety while reducing duplication
type MessageProcessor[M any, V comparable] struct {
    namespaceMap   map[string]models.DocumentFactory
    versionMap     map[string]V
    pathMaps       map[V]map[string]any
    requiredFields []string
}

The real power comes from how this generic processor eliminates repetitive code. Consider processing a Fedwire payment message:

// Without generics: Each message type needs 100+ lines of processing logic
func ParseCustomerCreditTransfer(data []byte) (*CustomerCreditTransfer, error) {
    // Manual namespace detection
    // Manual version lookup  
    // Manual field mapping
    // Manual validation
    // ... 100+ lines of error-prone code
}

// With generics: One-line processing with full type safety
func ParseXML(data []byte) (*MessageModel, error) {
    model, err := processor.ProcessMessage(data)
    if err != nil {
        return nil, err
    }
    return &model, nil

The processor automatically handles:

  1. Version detection: Extracts namespace and maps to typed version constant
  2. Document parsing: Creates appropriate document structure for the version
  3. Field mapping: Maps XML paths to Go struct fields using version-specific rules
  4. Validation: Ensures all required fields are present
  5. Error context: Wraps all errors with meaningful context

Here’s a real example from the codebase showing the complete flow:

// Process real Fedwire XML with automatic version detection
xmlData := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.10">
    <FIToFICstmrCdtTrf>
        <GrpHdr>
            <MsgId>MSG20240101001</MsgId>
            <CreDtTm>2024-01-01T10:00:00Z</CreDtTm>
            <NbOfTxs>1</NbOfTxs>
            <SttlmMtd>CLRG</SttlmMtd>
        </GrpHdr>
        <!-- ... more fields ... -->
    </FIToFICstmrCdtTrf>
</Document>`)

// Single line to parse with full type safety and validation
model, err := CustomerCreditTransfer.ParseXML(xmlData)
if err != nil {
    // Error includes context: "document creation failed: unsupported namespace"
    // or "field validation failed: GrpHdr.MsgId is required"
    log.Fatal(err)
}

// Compile-time type safety for accessing fields
fmt.Printf("Message ID: %s\n", model.MessageId)
fmt.Printf("Created: %s\n", model.CreatedDateTime)

// Version-specific fields are safely accessed
if model.Transaction != nil { // Only present in V8+
    fmt.Printf("UETR: %s\n", model.Transaction.UniqueEndToEndTransactionRef)
}

// Behind the scenes, each message type has its own processor
// that handles all the complexity of version detection, field mapping,
// and validation - but you don't need to interact with it directly

Idiomatic XML I/O with io.Reader and io.Writer

The library provides a clean, idiomatic interface for XML processing using Go’s standard io.Reader and io.Writer:

// ReadXML reads XML data from an io.Reader into the MessageModel
func (m *MessageModel) ReadXML(r io.Reader) error {
    data, err := io.ReadAll(r)
    if err != nil {
        return fmt.Errorf("reading XML: %w", err)
    }

    model, err := processor.ProcessMessage(data)
    if err != nil {
        return err
    }

    *m = model
    return nil
}

// WriteXML writes the MessageModel as XML to an io.Writer
func (m *MessageModel) WriteXML(w io.Writer, version ...PACS_008_001_VERSION) error {
    // Default to latest version
    ver := PACS_008_001_12
    if len(version) > 0 {
        ver = version[0]
    }

    // Create versioned document using factory
    doc, err := DocumentWith(*m, ver)
    if err != nil {
        return fmt.Errorf("creating document: %w", err)
    }

    // Write XML with proper formatting
    encoder := xml.NewEncoder(w)
    defer encoder.Close()
    encoder.Indent("", "  ")

    // Write XML declaration
    if _, err := w.Write([]byte(xml.Header)); err != nil {
        return fmt.Errorf("writing XML header: %w", err)
    }

    // Encode document
    if err := encoder.Encode(doc); err != nil {
        return fmt.Errorf("encoding XML: %w", err)
    }

    return encoder.Flush()
}

This enables natural usage patterns:

// Reading from any source
file, _ := os.Open("payment.xml")
defer file.Close()

var payment CustomerCreditTransfer.MessageModel
if err := payment.ReadXML(file); err != nil {
    log.Fatal(err)
}

// Writing to any destination  
var buf bytes.Buffer
if err := payment.WriteXML(&buf, CustomerCreditTransfer.PACS_008_001_10); err != nil {
    log.Fatal(err)
}

Factory pattern for version management

Managing multiple schema versions requires a robust factory system. The library uses generics to create type-safe factories:

// VersionedDocumentFactory provides generic factory functionality
type VersionedDocumentFactory[T models.ISODocument, V comparable] struct {
    versionMap   map[string]V
    namespaceMap map[V]string
    factories    map[V]func() T
}

// RegisterVersion registers a version with its namespace and factory function
func (f *VersionedDocumentFactory[T, V]) RegisterVersion(
    namespace string,
    version V,
    factory func() T,
) {
    f.versionMap[namespace] = version
    f.namespaceMap[version] = namespace
    f.factories[version] = factory
}

Registration becomes clean and type-safe:

factory := base.NewVersionedDocumentFactory[models.ISODocument, PACS_008_001_VERSION]()

// Register each version with its namespace and factory
factory.RegisterVersion(
    "urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08",
    PACS_008_001_08,
    func() models.ISODocument { return &pacs_008_001_08.Document{} },
)

factory.RegisterVersion(
    "urn:iso:std:iso:20022:tech:xsd:pacs.008.001.10", 
    PACS_008_001_10,
    func() models.ISODocument { return &pacs_008_001_10.Document{} },
)

Comprehensive validation

Version-specific validation logic becomes straightforward with generics:

// Version-specific field initialization and validation
func NewMessageForVersion(version PACS_008_001_VERSION) MessageModel {
    model := MessageModel{
        PaymentCore: base.PaymentCore{},
    }

    // Type-safe version-specific field initialization
    switch {
    case version >= PACS_008_001_08:
        model.Transaction = &TransactionFields{}
    }

    return model
}

// ValidateForVersion performs version-specific validation
func (m MessageModel) ValidateForVersion(version PACS_008_001_VERSION) error {
    // Validate core fields first
    if err := m.validateCoreFields(); err != nil {
        return err
    }

    // Version-specific validation
    if version >= PACS_008_001_08 {
        if m.Transaction == nil {
            return fmt.Errorf("TransactionFields required for version %s", version)
        }
        if err := m.Transaction.Validate(); err != nil {
            return err
        }
    }

    return nil
}

Performance and maintainability benefits

This architecture delivers significant benefits:

  • Type safety: Compile-time verification prevents runtime errors. Generic constraints ensure only valid types are used.

  • Performance: Zero reflection in hot paths. Generic specialization provides optimal performance.

  • Maintainability: Embedded structs eliminate duplication. Generic processors reduce code by 68% compared to individual implementations.

  • Testability: Factory pattern enables comprehensive testing with multiple versions. Type-safe interfaces make mocking straightforward.

  • Extensibility: Adding new message types requires minimal code. Version management is centralized and consistent.

Usage example

Here’s how the complete solution works in practice:

package main

import (
    "bytes"
    "fmt"
    "os"
    "time"
    
    "github.com/moov-io/wire20022/pkg/models/CustomerCreditTransfer"
    "github.com/moov-io/wire20022/pkg/base"
    "github.com/moov-io/wire20022/pkg/models"
)

func main() {
    // Create a new message with version-specific fields
    payment := CustomerCreditTransfer.NewMessageForVersion(
        CustomerCreditTransfer.PACS_008_001_10,
    )
    
    // Populate using embedded structs
    payment.MessageId = "MSG20240101001"
    payment.CreatedDateTime = time.Now()
    payment.NumberOfTransactions = "1"
    payment.SettlementMethod = models.SettlementCLRG
    payment.InstructionId = "INST001"
    payment.EndToEndId = "E2E001"
    
    // Version-specific fields are type-safe
    if payment.Transaction != nil {
        payment.Transaction.UniqueEndToEndTransactionRef = "UETR-001"
    }
    
    // Validate before processing
    if err := payment.ValidateForVersion(CustomerCreditTransfer.PACS_008_001_10); err != nil {
        log.Fatal(err)
    }
    
    // Write to XML using idiomatic interface
    file, _ := os.Create("payment.xml")
    defer file.Close()
    
    if err := payment.WriteXML(file, CustomerCreditTransfer.PACS_008_001_10); err != nil {
        log.Fatal(err)
    }
    
    // Read back from XML
    file.Seek(0, 0)
    var readPayment CustomerCreditTransfer.MessageModel
    if err := readPayment.ReadXML(file); err != nil {
        log.Fatal(err)
    }
    
    fmt.Printf("Processed payment: %s\n", readPayment.MessageId)
}

My overall thoughts

Modern Go provides powerful tools for building robust XML processing systems. By combining generics for type safety, embedded structs for composition, factory patterns for version management, and idiomatic interfaces for I/O, we can create maintainable, performant, and safe XML processing libraries.

The wire20022 library demonstrates these patterns at scale, processing complex financial messages with zero runtime reflection, comprehensive validation, and excellent performance. These techniques apply broadly to any XML processing challenge in Go.

Key takeaways:

  • Use embedded structs to eliminate duplication
  • Leverage generics for type-safe processing logic
  • Implement factory patterns for version management
  • Design idiomatic APIs with io.Reader and io.Writer
  • Validate comprehensively with compile-time safety

Explore the complete source code and other open source projects from Moov on our GitHub.

Further reading
Hosted onboarding: Built for faster activation
Product • 4m
Product release roundup: 2024
Product • 5m
Hey banks, let account holders fund accounts with a debit card
Product • 4m