zoobzio January 2, 2026 5 mins Edit this page

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.AA 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().

ModeBoundaryMatch
InspectPackagetargetPkg == sourcePkg
ScanModulestrings.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

OperationLock TypeContention
Cache readRLockLow (concurrent reads)
Cache writeLockMomentary (first access only)
Tag registry readRLockLow
Tag registry writeLockRare (registration only)

After initial extraction, operations are read-only.

Performance

OperationFirst CallSubsequent Calls
Inspect[T]()Reflection + cache writeCache read only
Scan[T]()Reflection + recursive scanCache 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