zoobzio January 23, 2026 Edit this page

Architecture

This page covers fig's internal structure for contributors and those who need to understand the implementation.

Component Overview

┌─────────────────────────────────────────────────────────────┐
│                         fig.Load                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐  │
│  │   sentinel   │───▶│   resolve    │───▶│   convert    │  │
│  │  (metadata)  │    │ (sourcing)   │    │  (parsing)   │  │
│  └──────────────┘    └──────────────┘    └──────────────┘  │
│                              │                              │
│                              ▼                              │
│                    ┌──────────────────┐                     │
│                    │ SecretProvider   │                     │
│                    │    (optional)    │                     │
│                    └──────────────────┘                     │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                        Validator                            │
│                       (optional)                            │
└─────────────────────────────────────────────────────────────┘

Sentinel Integration

fig uses sentinel for struct tag parsing and metadata caching. On package init, fig registers its tags:

func init() {
    sentinel.Tag("env")
    sentinel.Tag("secret")
    sentinel.Tag("default")
    sentinel.Tag("required")
}

When Load is called, sentinel scans the struct once and caches the metadata. Subsequent loads of the same type reuse the cached metadata, avoiding reflection overhead.

Sentinel provides:

  • TryScan[T]() — extract struct metadata with error handling
  • Lookup(fqdn) — retrieve cached metadata for nested structs
  • FieldMetadata — per-field information including tags, type, and index

Resolution Flow

The resolve.go file handles value sourcing:

  1. Parse tags — extract env, secret, default, required from sentinel metadata
  2. Query sources — try secret provider, then environment, then default
  3. Check required — if no value found and required:"true", return error
  4. Convert — parse string to target type
  5. Set — assign the converted value to the struct field
func resolveField(ctx context.Context, tags fieldTags, provider SecretProvider) (string, bool, error) {
    // 1. Try secret provider
    if tags.secret != "" && provider != nil {
        val, err := provider.Get(ctx, tags.secret)
        if err == nil {
            return val, true, nil
        }
        if err != ErrSecretNotFound {
            return "", false, err  // Propagate real errors
        }
    }

    // 2. Try environment variable
    if tags.env != "" {
        if val := os.Getenv(tags.env); val != "" {
            return val, true, nil
        }
    }

    // 3. Try default value
    if tags.defValue != "" {
        return tags.defValue, true, nil
    }

    return "", false, nil
}

The bool return indicates whether a value was found. This distinguishes "no value" from "empty string value".

Type Conversion

The convert.go file handles string-to-type parsing:

  1. Pointer types — unwrap, convert element, wrap in pointer
  2. TextUnmarshaler — delegate to the type's UnmarshalText method
  3. Primitive types — use strconv functions
  4. Duration — special case using time.ParseDuration
  5. Slices — comma-split for []string

Conversion uses reflection to inspect the target type and select the appropriate parser.

Nested Struct Handling

When a field is a struct without fig tags, fig treats it as a container and recurses:

if field.Kind == sentinel.KindStruct {
    tags := parseFieldTags(field)
    if tags.env == "" && tags.secret == "" && tags.defValue == "" && !tags.required {
        fqdn := field.ReflectType.PkgPath() + "." + field.ReflectType.Name()
        if nestedMeta, found := sentinel.Lookup(fqdn); found {
            loadFromMetadata(ctx, fieldVal, nestedMeta, provider)
        }
        continue
    }
}

Nested struct metadata is looked up from sentinel's cache using the fully-qualified type name.

Design Q&A

Why use sentinel instead of parsing tags directly?

Sentinel provides caching. Parsing struct tags via reflection is expensive. For applications that load configuration once at startup, this doesn't matter. For libraries that might call Load repeatedly, caching prevents redundant work.

Why is there only one secret provider?

Simplicity. Most applications use one secret store. If you need multiple providers, compose them into a single provider that delegates. See Composing Providers for patterns.

Why does secret take precedence over env?

Secrets represent the most specific, secure configuration source. They're typically managed by infrastructure teams and override developer defaults. Environment variables are convenient but less secure—they appear in process listings, crash dumps, and logs.

Why return FieldError instead of just error?

Field context is essential for debugging. When loading a config with 20 fields, knowing which one failed saves time. FieldError wraps the underlying error while preserving field name.

Performance

OperationComplexity
First Load of a typeO(n) where n = number of fields
Subsequent Load of same typeO(n) with cached metadata
Secret provider callProvider-dependent
Type conversionO(1) for primitives, O(m) for slices

Memory allocation is minimal—fig doesn't allocate intermediate structures beyond what's needed for the config struct itself.

Next Steps