Architecture
How sentinel's components work together to extract, cache, and query struct metadata.
Component Overview
┌─────────────────────────────────────────────────────────────┐
│ Public API │
│ Inspect[T]() Scan[T]() Browse() Lookup() Schema() │
└─────────────────────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Sentinel Instance │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Cache │ │ Tag Registry│ │ Module Path │ │
│ │ (RWMutex) │ │ (RWMutex) │ │ (debug.BuildInfo) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Extraction Pipeline │
│ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ extractMetadata │───▶│ extractMetadataInternal │ │
│ │ (single type) │ │ (with cycle detection) │ │
│ └──────────────────┘ └───────────────┬──────────────┘ │
│ │ │
│ ┌──────────────────┐ ┌───────────────▼──────────────┐ │
│ │extractFieldMeta │◀───│ extractRelationships │ │
│ │ (tags, kinds) │ │ (recursive if Scan mode) │ │
│ └──────────────────┘ └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
The singleton initialises at package load, detecting the module path via debug.ReadBuildInfo().
Extraction Pipeline
Inspect Flow
Inspect[T]()
│
├─▶ Check cache ─── hit ──▶ Return cached
│
└─▶ miss
│
├─▶ extractFieldMetadata(t)
├─▶ extractRelationships(t, nil) // nil = no recursion
└─▶ Cache and return
Scan Flow
Scan[T]()
│
▼
Create visited = map[string]bool{}
│
▼
extractMetadataInternal(t, visited)
│
├─▶ Check visited ─── yes ──▶ Return (cycle prevention)
│
└─▶ Mark visited[fqdn] = true
│
├─▶ extractFieldMetadata(t)
├─▶ extractRelationships(t, visited)
│ │
│ └─▶ For each relationship in module:
│ └─▶ extractMetadataInternal(relatedType, visited)
│
└─▶ Cache and return
The visited map serves dual purpose: cycle detection and mode signal (non-nil triggers recursion).
Cycle Detection
Circular references are common in domain models:
type A struct { B *B }
type B struct { A *A }
When scanning A: mark visited → extract A.B → recurse to B → mark visited → extract B.A → A already visited → return. No infinite loop.
Domain Boundaries
Sentinel uses two-tier boundary detection to control relationship discovery:
Package boundary: In Inspect mode, only types in the exact same package create relationships.
Module boundary: In Scan mode, types anywhere in your module are recursed into, using the module path from debug.ReadBuildInfo().
| Mode | Boundary | Match |
|---|---|---|
| Inspect | Package | targetPkg == sourcePkg |
| Scan | Module | strings.HasPrefix(targetPkg, modulePath) |
If build info is unavailable, modulePath is empty and Scan degrades to Inspect behaviour—no recursion, but no crash.
Tag Extraction
Field metadata includes struct tags. Eight common tags are always extracted:
json, validate, db, scope, encrypt, redact, desc, example
Custom tags registered via Tag() are checked dynamically at extraction time. The registry is protected by sync.RWMutex.
Field Kind Classification
Each field is categorized into a FieldKind: scalar, pointer, slice, struct, map, or interface. This enables conditional logic without parsing type strings.
Thread Safety
| Operation | Lock Type | Contention |
|---|---|---|
| Cache read | RLock | Low (concurrent reads) |
| Cache write | Lock | Momentary (first access only) |
| Tag registry read | RLock | Low |
| Tag registry write | Lock | Rare (registration only) |
After initial extraction, operations are read-only.
Performance
| Operation | First Call | Subsequent Calls |
|---|---|---|
Inspect[T]() | Reflection + cache write | Cache read only |
Scan[T]() | Reflection + recursive scan | Cache read only |
Browse() | — | O(n) key copy |
Lookup() | — | O(1) map access |
Schema() | — | O(n) map copy |
Design Q&A
Why is the cache global?
Go's type system is global—one program, one set of types. A global cache mirrors this. There's no benefit to per-instance caches since the underlying type system is shared.
Why package-level functions instead of methods?
Go doesn't permit methods with type parameters. Inspect[T]() must be a function.
Why does Scan respect module boundaries?
Without boundaries, scanning a type that references time.Time or sql.NullString would pull in standard library and third-party types. Module awareness keeps focus on your domain.
What if build info isn't available?
Sentinel degrades gracefully—Scan behaves like Inspect, extracting only the requested type without recursion.
Next Steps
- Scanning Guide — practical Inspect vs Scan usage
- Testing Guide — testing patterns with sentinel
- API Reference — complete function documentation