Documents
Model Re-Export Layer
Model Re-Export Layer
Type
Topic
Status
Published
Created
Feb 27, 2026
Updated
Mar 17, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

Model Re-Export Layer#

⚠️ HISTORICAL DOCUMENT: The Model Re-Export Layer was completely removed in PR #404. This page documents the historical architecture for reference only. External projects should now import directly from pkg/model/, pkg/parser/, and pkg/schema/opnsense/.

Historical Context#

The Model Re-Export Layer was an architectural pattern in the opnDossier project that provided a stable public interface over internal XML schema types. Located in the internal/model/ package, this layer contained 92 type aliases following the pattern type X = schema.Y that masked the underlying internal/schema/ XML Data Transfer Objects (DTOs) from downstream consumers. The re-export layer served as the primary abstraction boundary between XML parsing concerns and application logic, enabling the project to support multiple firewall device types (OPNsense, pfSense) through a platform-agnostic CommonDevice domain model.

The layer served a dual purpose: it acted as both a type re-export mechanism and the home for critical parsing infrastructure including the ParserFactory (renamed to Factory in the migration) and DeviceParser interface. This design allowed 87+ consumer files across CLI commands, processors, converters, and audit plugins to operate on the CommonDevice domain model without coupling to OPNsense-specific XML structures. The re-export layer was designed as a temporary migration seam to facilitate backward compatibility during the multi-device architecture refactoring.

The layer was completely removed in PR #404, which moved all types to public pkg/ packages with SemVer stability guarantees. The migration eliminated the 13 re-export files containing 469 lines and 93 type aliases, replacing them with direct imports from well-structured public API packages.

Historical Architecture (Pre-PR #404)#

Layered Model Structure#

The opnDossier codebase implemented a multi-layered architecture that separated XML parsing from domain logic:

Loading diagram...

⚠️ Note: This diagram reflects the historical architecture. After PR #404, packages are located in pkg/schema/opnsense/, pkg/parser/opnsense/, and pkg/model/. See "Current Architecture" section below.

Historical Package Responsibilities#

internal/schema/ — XML Data Transfer Objects (DTOs) with xml:"" struct tags that mirror the OPNsense config.xml structure. These types were tightly coupled to XML unmarshaling and should not be imported by application logic.

internal/model/opnsense/ — Device-specific parser and converter implementations that transform schema DTOs into the CommonDevice domain model. This was the only package that imported internal/schema/.

internal/model/common/ — Platform-agnostic domain model with no XML tags. All application logic (processors, converters, markdown generators, audit plugins) operated on CommonDevice rather than XML-specific types.

internal/model/ — The re-export layer providing type aliases and constructor wrappers over internal/schema/, plus the ParserFactory implementation and DeviceParser interface for multi-device support.

Historical Type Aliases and Constructor Wrappers (Pre-PR #404)#

The re-export layer consisted of 92 type aliases organized across 15 domain-specific files. Each alias followed the pattern type X = schema.Y, providing a stable import path while delegating to the underlying schema implementation.

File Organization#

The 15 files in internal/model/ were organized by functional domain:

FileType AliasesConstructor FunctionsDomain
security.go176Firewall rules, NAT, IPsec, IDS
services.go164DNS, DHCP, NTP, Syslog, Monit
interfaces.go140VLANs, bridges, GIF, GRE, LAGG
system.go100System config, users, firmware
vpn.go105OpenVPN, WireGuard
network.go80Gateways, static routes
dhcp.go50DHCP server configuration
common.go30BoolFlag, ChangeMeta
opnsense.go31Root document types
certificates.go20Certificate authorities
packages.go22Package metadata
high_availability.go10HA sync configuration
revision.go10Config revision tracking
factory.go01ParserFactory for device detection
factory_export.go20CommonDevice re-exports

Note: All files listed above were removed in PR #404. These types now live directly in pkg/schema/opnsense/ or pkg/model/.

Type Alias Patterns (Historical Examples)#

Simple Type Alias — Minimal wrapper with documentation inheritance:

// BoolFlag provides custom XML marshaling for OPNsense boolean values.
// Type alias to schema.BoolFlag - all methods are inherited.
type BoolFlag = schema.BoolFlag

Type Alias with Constructor — Wraps both type and initialization logic:

// OpnSenseDocument is the root of the OPNsense configuration.
type OpnSenseDocument = schema.OpnSenseDocument

// NewOpnSenseDocument returns a new OpnSenseDocument with all slice 
// and map fields initialized to prevent nil pointer dereferences.
func NewOpnSenseDocument() *OpnSenseDocument {
    return schema.NewOpnSenseDocument()
}

Re-Exported Constantsfactory_export.go provided constants alongside type aliases:

type CommonDevice = common.CommonDevice
type DeviceType = common.DeviceType

const (
    DeviceTypeOPNsense = common.DeviceTypeOPNsense
    DeviceTypePfSense = common.DeviceTypePfSense
    DeviceTypeUnknown = common.DeviceTypeUnknown
)

Constructor Functions (Historical)#

The re-export layer provided 18+ constructor functions that delegated to the underlying schema package:

  • Factory: NewParserFactory() (renamed to NewFactory() with XML decoder injection)
  • Security: NewFirewall(), NewIDS(), NewIPsec(), NewSwanctl()
  • VPN: NewOpenVPN(), NewWireGuard(), NewClientExport()
  • Services: NewDNSMasq(), NewSyslog(), NewMonit()

These constructors ensured proper initialization of nested slice and map fields, preventing nil pointer dereferences during configuration manipulation.

Current Architecture (Post-PR #404)#

Public API Structure#

PR #404 moved all types to pkg/ packages with SemVer stability guarantees:

pkg/
├── model/ # Platform-agnostic domain model
│ ├── device.go # CommonDevice, FirewallRule, Interface
│ ├── vpn.go # VPN configurations
│ ├── nat.go # NAT rules
│ ├── warning.go # ConversionWarning
│ └── ...
├── schema/
│ └── opnsense/ # OPNsense XML DTOs
│ ├── opnsense.go # OpnSenseDocument root
│ ├── security.go # Firewall, NAT structures
│ ├── common.go # BoolFlag, ChangeMeta
│ └── ...
└── parser/ # Parser interfaces + factory
    ├── parser.go # DeviceParser interface
    ├── factory.go # Factory (renamed from ParserFactory)
    └── opnsense/ # OPNsense-specific implementation
        ├── parser.go # XML → schema DTO
        └── converter.go # schema DTO → CommonDevice

Factory and Parser Usage#

The Factory (renamed from ParserFactory) now requires XML decoder injection to keep pkg/ free of internal/ dependencies:

import (
    "github.com/EvilBit-Labs/opnDossier/internal/cfgparser"
    common "github.com/EvilBit-Labs/opnDossier/pkg/model"
    "github.com/EvilBit-Labs/opnDossier/pkg/parser"
)

// Create factory with injected XML decoder
factory := parser.NewFactory(cfgparser.NewXMLParser())
file, err := os.Open("config.xml")
if err != nil {
    log.Fatal(err)
}
defer file.Close()

// Auto-detect device type and parse to CommonDevice
device, warnings, err := factory.CreateDevice(context.Background(), file, "", false)
if err != nil {
    log.Fatal(err)
}

// Log conversion warnings
for _, w := range warnings {
    log.Printf("Warning: %s (field=%s, severity=%s)", w.Message, w.Field, w.Severity)
}

// Operate on platform-agnostic CommonDevice
fmt.Printf("Device: %s, Version: %s\n", device.DeviceType, device.Version)

Interface Injection and Boundary Enforcement#

The XMLDecoder interface defined in pkg/parser/factory.go enables pkg/ to remain free of internal/ imports:

// pkg/parser/factory.go
type XMLDecoder interface {
    Parse(ctx context.Context, r io.Reader) (*schema.OpnSenseDocument, error)
    ParseAndValidate(ctx context.Context, r io.Reader) (*schema.OpnSenseDocument, error)
}

func NewFactory(decoder XMLDecoder) *Factory {
    return &Factory{xmlDecoder: decoder}
}

Why interface injection is necessary: pkg/ packages must never import internal/ packages to maintain public API purity. Go enforces the internal/ access boundary at the module level—external consumers running go get would encounter build errors if pkg/ imported internal/cfgparser. The concrete cfgparser.NewXMLParser() implementation is wired at the cmd/ layer, allowing pkg/parser to remain dependency-free.

Go structural typing advantage: The Parser in pkg/parser/opnsense/ defines its own unexported xmlDecoder interface with identical method signatures. Go's structural typing automatically satisfies this interface without requiring an explicit import—any type that implements Parse() and ParseAndValidate() with matching signatures will work.

Detailed implementation: See docs/solutions/architecture-issues/pkg-internal-import-boundary.md for the complete explanation of boundary violations, interface injection patterns, and prevention strategies.

Import Pattern Changes#

Before PR #404:

import (
    "github.com/EvilBit-Labs/opnDossier/internal/model"
    "github.com/EvilBit-Labs/opnDossier/internal/model/common"
)

device, warnings, err := model.NewParserFactory().CreateDevice(ctx, r, "", false)

After PR #404:

import (
    common "github.com/EvilBit-Labs/opnDossier/pkg/model"
    "github.com/EvilBit-Labs/opnDossier/pkg/parser"
)

factory := parser.NewFactory(cfgparser.NewXMLParser())
device, warnings, err := factory.CreateDevice(ctx, r, "", false)

All 104 consumer files (CLI commands, processors, converters, audit plugins) were mechanically updated with these import path changes.

Historical Consumer Integration (Pre-PR #404)#

DeviceParser Interface#

The DeviceParser interface provides the contract for device-specific parsing implementations:

type DeviceParser interface {
    Parse(ctx context.Context, r io.Reader) (*common.CommonDevice, error)
    ParseAndValidate(ctx context.Context, r io.Reader) (*common.CommonDevice, error)
}

Both methods accept an XML configuration stream and return a *common.CommonDevice, abstracting away device-specific schema details from consumers.

ParserFactory and Device Type Detection (Historical)#

The historical ParserFactory performed automatic device type detection by inspecting the XML root element:

factory := model.NewParserFactory()
file, err := os.Open("config.xml")
if err != nil {
    log.Fatal(err)
}
defer file.Close()

// Auto-detect device type and parse to CommonDevice
device, err := factory.CreateDevice(context.Background(), file, "", false)
if err != nil {
    log.Fatal(err)
}

// Now operate on platform-agnostic CommonDevice
fmt.Printf("Device: %s, Version: %s\n", device.DeviceType, device.Version)

The factory method delegated to the appropriate DeviceParser implementation (internal/model/opnsense/parser.go for OPNsense), which internally used internal/cfgparser/xml.go for low-level XML parsing and internal/model/opnsense/converter.go to transform schema DTOs into the CommonDevice domain model.

Import Pattern Analysis (Historical)#

The architecture enforced clean separation between XML processing and domain logic:

Direct Schema Importers (5 files only):

  • internal/model/opnsense/parser.go — Unmarshals XML to schema.OpnSenseDocument
  • internal/model/opnsense/converter.go — Converts schema DTOs to *common.CommonDevice
  • internal/cfgparser/xml.go — Low-level XML parsing primitives
  • internal/validator/opnsense.go — Structural validation of schema DTOs
  • tools/docgen/main.go — Documentation generator

CommonDevice Importers (87+ files):

  • All CLI commands (cmd/)
  • All converter implementations (internal/converter/)
  • All processor logic (internal/processor/)
  • All audit plugins (internal/audit/)

This import analysis confirmed that only the XML parsing layer interacted with internal/schema/; all application logic operated on *common.CommonDevice. The re-export layer successfully isolated consumers from XML-specific implementation details.

Current State: After PR #404, consumers import directly from pkg/model/ and pkg/parser/. The pkg/schema/opnsense/ package is only imported by pkg/parser/opnsense/ (parser and converter).

Migration Execution (Completed in PR #404)#

Rationale for Removal#

GitHub issue #301 outlined the plan to eliminate the re-export layer as part of exposing opnDossier's types and parsing infrastructure as public packages under pkg/ with SemVer stability guarantees. The 13 re-export files containing 469 lines and 93 type aliases created maintenance overhead: every change to internal/schema/ or internal/model/common/ required corresponding updates to the re-export layer. By moving packages to a well-structured pkg/ hierarchy, external Go projects can directly import opnDossier's domain model and parsing infrastructure without depending on internal implementation details.

Status: ✅ Completed in PR #404. The re-export layer has been fully removed, with all types moved to pkg/ packages.

Executed Migration Plan#

PR #404 implemented a four-phase approach:

Phase 1: Create pkg/ Structure ✅#

Established the new public API hierarchy:

  • pkg/model/ — Platform-agnostic domain model (moved from internal/model/common/)
  • pkg/schema/opnsense/ — OPNsense XML DTOs (moved from internal/schema/)
  • pkg/parser/ — Parser interfaces and factory (extracted from internal/model/factory.go)
  • pkg/parser/opnsense/ — OPNsense-specific implementation (moved from internal/model/opnsense/)

Phase 2: Update Internal Imports ✅#

Mechanical find-and-replace across 104 files:

internal/model/common → pkg/model
internal/schema → pkg/schema/opnsense
internal/model/opnsense → pkg/parser/opnsense
internal/model (factory) → pkg/parser
internal/model (aliases) → pkg/schema/opnsense or pkg/model

Phase 3: Remove Re-export Layer ✅#

Deleted the 13 re-export files: common.go, security.go, services.go, vpn.go, interfaces.go, system.go, network.go, dhcp.go, packages.go, opnsense.go, certificates.go, revision.go, high_availability.go. The entire internal/model/ directory was removed. Consumers now import directly from pkg/schema/opnsense or pkg/model.

Phase 4: Validation and Documentation ✅#

  • Ran just ci-check to verify all tests pass (30 test packages)
  • Updated .golangci.yml revive var-naming exclusion: internal/model/common/pkg/model/
  • Updated AGENTS.md, project structure docs, and .github/copilot-instructions.md with new import patterns
  • External import validation: go get github.com/EvilBit-Labs/opnDossier/pkg/model (available after merge + tag)

Final Public API Structure#

The completed structure provides SemVer stability:

pkg/
├── model/ # Platform-agnostic domain model
│ ├── device.go # CommonDevice, FirewallRule, Interface
│ ├── vpn.go # VPN configurations
│ ├── nat.go # NAT rules
│ ├── warning.go # ConversionWarning
│ └── ...
├── schema/
│ └── opnsense/ # OPNsense XML DTOs
│ ├── opnsense.go # OpnSenseDocument root
│ ├── security.go # Firewall, NAT structures
│ └── ...
└── parser/ # Parser interfaces + factory
    ├── parser.go # DeviceParser interface
    ├── factory.go # Factory
    └── opnsense/ # OPNsense-specific implementation
        ├── parser.go # XML → schema DTO
        └── converter.go # schema DTO → CommonDevice

Key Changes in PR #404#

  1. ParserFactoryFactory: Renamed per Go naming conventions (avoid stuttering)
  2. XML decoder injection: Factory now requires XMLDecoder at construction (NewFactory(cfgparser.NewXMLParser())) to keep pkg/ free of internal/ dependencies
  3. ConversionWarning moved: From internal/model/common/warning.go to pkg/model/warning.go (new file, content restructured)
  4. Package name changes: internal/model/common (package common) became pkg/model (package model)
  5. Import aliases preserved: All consumers continue using common "..." and schema "..." aliases — no code changes beyond import paths

Timeline and Dependencies#

Completed: PR #404 merged, completing the broader multi-device architecture refactoring. This was the final implementation phase following:

Historical Gotchas and Best Practices#

Type Alias Removal Breaks All Consumers (Historical)#

Problem: Removing a type alias (type X = pkg.Y) is an immediate breaking change for all consumers that reference X.

Example: If internal/model/security.go contains type Firewall = schema.Firewall and this alias is removed, every file importing model.Firewall will fail to compile.

Mitigation:

  1. Grep exhaustively — Search for model.Firewall (or the package name + type) across the entire codebase before removal
  2. Atomic updates — Update all references in a single commit to prevent partial migration states
  3. CI validation — Run just ci-check to catch any missed references before merging

JSON vs YAML omitempty on Struct Fields#

Problem: Go's encoding/json ignores omitempty on struct-typed fields—empty structs are always serialized as {}. However, gopkg.in/yaml.v3 respects omitempty and can omit empty structs entirely.

Enforced pattern:

// Correct: JSON omitempty removed, YAML omitempty retained
System System `json:"system" yaml:"system,omitempty"`
NAT NATConfig `json:"nat" yaml:"nat,omitempty"`

Enforcement: The modernize linter check in Go 1.26+ automatically flags omitempty on JSON struct fields and enforces removal.

Result:

  • JSON output: Always includes struct fields (even if zero-valued)
  • YAML output: Omits struct fields when zero-valued

Package Naming: "common" Triggers Linter Warnings (Historical)#

Problem: The revive linter flagged common as a meaningless package name, triggering var-naming: avoid meaningless package names warnings.

Justification: The name common was chosen to convey "platform-agnostic domain model" shared across multiple device types (OPNsense, pfSense).

Historical solution in .golangci.yml:

- path: internal/model/common/
  linters: [revive]
  text: "var-naming: avoid meaningless package names"

Current state: After PR #404, the package was renamed from internal/model/common to pkg/model, and the linter exclusion path was updated to pkg/model/.

Constants Must Be Re-Exported (Historical)#

Problem: Constants defined in source packages (internal/schema/, internal/model/common/) were not automatically re-exported through type aliases.

Example: factory_export.go had to explicitly re-export constants:

type DeviceType = common.DeviceType

const (
    DeviceTypeOPNsense = common.DeviceTypeOPNsense
    DeviceTypePfSense = common.DeviceTypePfSense
    DeviceTypeUnknown = common.DeviceTypeUnknown
)

Implication: Any new constant added to common.DeviceType required a corresponding re-export in internal/model/factory_export.go.

Current state: After PR #404, constants are imported directly from pkg/model/ (e.g., common.DeviceTypeOPNsense). No re-export mechanism required.

CommonDevice Convenience Methods (Current)#

The CommonDevice struct (now in pkg/model/device.go) provides convenience methods for checking the presence of configuration sections:

// HasDHCP reports whether the device has any DHCP scope configuration.
func (d *CommonDevice) HasDHCP() bool

// HasInterfaces reports whether the device has any interface configuration.
func (d *CommonDevice) HasInterfaces() bool

// HasNATConfig reports whether the device has meaningful NAT configuration.
func (d *CommonDevice) HasNATConfig() bool

// HasRoutes reports whether the device has any static route configuration.
func (d *CommonDevice) HasRoutes() bool

// HasVLANs reports whether the device has any VLAN configuration.
func (d *CommonDevice) HasVLANs() bool

All methods return false if the device pointer is nil, making them safe for use without explicit nil checks. These methods are used by the diff engine for section-level change detection.

HasData() Value-Type Pattern#

For value-type structs (structs that are not pointers), presence detection requires checking whether the struct contains meaningful data. The HasData() method pattern provides this capability:

// NATConfig.HasData checks for meaningful NAT configuration
func (c NATConfig) HasData() bool {
    return c.OutboundMode != "" ||
        len(c.OutboundRules) > 0 ||
        len(c.InboundRules) > 0 ||
        c.ReflectionDisabled ||
        c.PfShareForward ||
        c.BiNATEnabled
}

Pattern Distinction:

  • Pointer types — Nil checks suffice (e.g., if device.Firewall != nil)
  • Value typesHasData() provides presence detection (e.g., device.NAT.HasData())

This distinction is critical for the diff engine's section-level change detection. When a struct is embedded by value in CommonDevice (like NAT NATConfig), the field always exists but may be zero-valued. The HasData() method determines whether the zero-valued struct represents "no configuration" or simply "default configuration."

Usage: CommonDevice convenience methods delegate to HasData() where appropriate. For example, HasNATConfig() calls d.NAT.HasData() to determine NAT presence. The diff engine calls HasData() directly when comparing value-type sections.

Best Practices for Public Package Usage (Post-PR #404)#

  1. Import from pkg/model/, not historical paths — Use common "github.com/EvilBit-Labs/opnDossier/pkg/model" for platform-agnostic domain model
  2. Never import internal/ from external projectspkg/ packages are the stable public API boundary
  3. Use Factory with XML decoder injectionparser.NewFactory(cfgparser.NewXMLParser()) for CLI; external consumers provide their own XMLDecoder implementation
  4. Use convenience methods for presence checksdevice.HasInterfaces() instead of manual len(device.Interfaces) > 0 checks
  5. Handle conversion warnings — All parsing operations return (*CommonDevice, []ConversionWarning, error) — log warnings without treating as fatal errors
  6. Verify boundary violations before committing — Run grep -rn 'internal/' --include='*.go' pkg/ | grep -v _test.go to ensure pkg/ packages don't import internal/
  7. Use interface injection for cross-boundary dependencies — Define interfaces in pkg/ and inject concrete implementations at cmd/. See pkg/parser.XMLDecoder and docs/solutions/architecture-issues/pkg-internal-import-boundary.md for the canonical example
  8. Leverage Go structural typingpkg/ sub-packages can define their own unexported interfaces that internal/ types satisfy without explicit imports

Current Code Files (Post-PR #404)#

File PathPurposeStatus
Public API (pkg/)
pkg/model/device.goCommonDevice platform-agnostic domain modelActive
pkg/model/warning.goConversionWarning typeActive (new in PR #404)
pkg/parser/factory.goFactory implementation, device type detectionActive (renamed from ParserFactory)
pkg/parser/opnsense/parser.goOPNsense XML parser implementationActive
pkg/parser/opnsense/converter.goSchema DTO to CommonDevice converterActive
pkg/schema/opnsense/opnsense.goOpnSenseDocument root XML DTOActive
pkg/schema/opnsense/security.goFirewall/NAT XML DTOsActive
pkg/schema/opnsense/common.goBoolFlag, ChangeMeta, RuleLocationActive
Internal Infrastructure
internal/cfgparser/xml.goLow-level XML parser and validatorActive
internal/validator/opnsense.goSchema DTO validation logicActive
Configuration
.golangci.ymlLinter configuration with pkg/model/ exclusionActive
AGENTS.mdCoding standards and linter documentationActive

Historical Code Files (Removed in PR #404)#

All files below were removed when the re-export layer was eliminated:

File PathPurposeStatus
internal/model/common.goCommon type aliases (BoolFlag, ChangeMeta, RuleLocation)❌ Removed
internal/model/security.goSecurity/firewall type aliases and constructors❌ Removed
internal/model/services.goService configuration type aliases❌ Removed
internal/model/vpn.goVPN type aliases (OpenVPN, WireGuard)❌ Removed
internal/model/interfaces.goNetwork interface type aliases❌ Removed
internal/model/system.goSystem configuration type aliases❌ Removed
internal/model/network.goNetwork/routing type aliases❌ Removed
internal/model/dhcp.goDHCP type aliases❌ Removed
internal/model/packages.goPackage/service type aliases❌ Removed
internal/model/opnsense.goRoot document type aliases❌ Removed
internal/model/certificates.goCertificate type aliases❌ Removed
internal/model/revision.goConfiguration revision type alias❌ Removed
internal/model/high_availability.goHA sync type alias❌ Removed
internal/model/factory.goParserFactory implementation (historical)❌ Removed (moved to pkg/parser/factory.go)
internal/model/factory_export.goCommonDevice and DeviceType re-exports❌ Removed
internal/model/common/device.goCommonDevice (historical location)❌ Removed (moved to pkg/model/device.go)
internal/model/common/warning.goConversionWarning (historical location)❌ Removed (moved to pkg/model/warning.go)
internal/schema/opnsense.goOpnSenseDocument (historical location)❌ Removed (moved to pkg/schema/opnsense/opnsense.go)
internal/schema/security.goFirewall/NAT (historical location)❌ Removed (moved to pkg/schema/opnsense/security.go)
internal/model/opnsense/parser.goOPNsense parser (historical location)❌ Removed (moved to pkg/parser/opnsense/parser.go)
internal/model/opnsense/converter.goSchema converter (historical location)❌ Removed (moved to pkg/parser/opnsense/converter.go)