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 handlingLookup(fqdn)— retrieve cached metadata for nested structsFieldMetadata— per-field information including tags, type, and index
Resolution Flow
The resolve.go file handles value sourcing:
- Parse tags — extract
env,secret,default,requiredfrom sentinel metadata - Query sources — try secret provider, then environment, then default
- Check required — if no value found and
required:"true", return error - Convert — parse string to target type
- 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:
- Pointer types — unwrap, convert element, wrap in pointer
- TextUnmarshaler — delegate to the type's
UnmarshalTextmethod - Primitive types — use
strconvfunctions - Duration — special case using
time.ParseDuration - 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
| Operation | Complexity |
|---|---|
First Load of a type | O(n) where n = number of fields |
Subsequent Load of same type | O(n) with cached metadata |
| Secret provider call | Provider-dependent |
| Type conversion | O(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
- Reference — full API documentation
- Testing — test code that uses fig
- Secret Providers — implementing custom providers