zoobzio December 26, 2025 Edit this page

Concepts

Codec vs Processor

Codec is a simple interface for marshaling and unmarshaling:

type Codec interface {
    ContentType() string
    Marshal(v any) ([]byte, error)
    Unmarshal(data []byte, v any) error
}

Use a codec directly when you need basic encoding without transforms.

ProcessorT provides boundary-aware transforms. Optionally wraps a codec for serialization:

type Processor[T Cloner[T]] struct {
    // ...
}

// Primary API: T -> T
func (p *Processor[T]) Receive(ctx context.Context, obj T) (T, error)
func (p *Processor[T]) Load(ctx context.Context, obj T) (T, error)
func (p *Processor[T]) Store(ctx context.Context, obj T) (T, error)
func (p *Processor[T]) Send(ctx context.Context, obj T) (T, error)

// Secondary codec-aware API
func (p *Processor[T]) Decode(ctx context.Context, data []byte) (*T, error)
func (p *Processor[T]) Read(ctx context.Context, data []byte) (*T, error)
func (p *Processor[T]) Write(ctx context.Context, obj *T) ([]byte, error)
func (p *Processor[T]) Encode(ctx context.Context, obj *T) ([]byte, error)

Use a processor when data needs different treatment at different boundaries.

The Four Boundaries

Data flows through four boundaries:

External World              Your Application              Storage
     │                            │                          │
     │  ──── Receive ────>        │                          │
     │                            │  ──── Store ────>        │
     │                            │  <──── Load ─────        │
     │  <──── Send ──────         │                          │
     │                            │                          │
BoundaryOperationTransformsExample
ReceiveReceive()receive.hashHash passwords from API requests
LoadLoad()load.decryptDecrypt fields from database
StoreStore()store.encryptEncrypt fields for database
SendSend()send.mask, send.redactMask PII in API responses

ClonerT Constraint

All types used with Processor[T] must implement Cloner[T]:

type Cloner[T any] interface {
    Clone() T
}

This ensures the original value is never modified during transforms. All four operations clone before transforming.

Deep Copy Requirement

WARNING: Clone must return a true deep copy. A shallow copy (return u) is only safe for types with no reference fields.

Field TypeDeep Copy Required?
string, int, bool, etc.No (copied by value)
[]T (slice)Yes
map[K]VYes
*T (pointer)Yes
Struct with any of the aboveYes

Simple Value Types

For types with only primitive fields:

type User struct {
    ID   string
    Name string
    Age  int
}

func (u User) Clone() User { return u }  // Safe: all fields are values

Types with Reference Fields

For types with slices, maps, or pointers:

type User struct {
    ID       string
    Tags     []string          // Slice
    Settings map[string]string // Map
    Profile  *Profile          // Pointer
}

func (u User) Clone() User {
    clone := User{ID: u.ID}

    // Deep copy slice
    if u.Tags != nil {
        clone.Tags = make([]string, len(u.Tags))
        copy(clone.Tags, u.Tags)
    }

    // Deep copy map
    if u.Settings != nil {
        clone.Settings = make(map[string]string, len(u.Settings))
        for k, v := range u.Settings {
            clone.Settings[k] = v
        }
    }

    // Deep copy pointer
    if u.Profile != nil {
        p := *u.Profile
        clone.Profile = &p
    }

    return clone
}

Verification

Test that modifying the clone does not affect the original:

func TestClone(t *testing.T) {
    original := User{Tags: []string{"admin"}}
    clone := original.Clone()
    clone.Tags[0] = "user"

    if original.Tags[0] != "admin" {
        t.Error("Clone modified original")
    }
}

Boundary Tags

Struct tags declare which transforms apply at which boundaries:

type User struct {
    // Hash on receive (incoming data)
    Password string `receive.hash:"sha256"`

    // Encrypt on store, decrypt on load
    Email string `store.encrypt:"aes" load.decrypt:"aes"`

    // Mask on send (outgoing data)
    SSN string `send.mask:"ssn"`

    // Redact on send
    Token string `send.redact:"[HIDDEN]"`
}

Tag Format

boundary.operation:"algorithm"
BoundaryOperations
receivehash
loaddecrypt
storeencrypt
sendmask, redact

Tag Values

Tag values reference registered algorithms:

// Tag value "aes" maps to EncryptAES
Email string `store.encrypt:"aes"`

// Registration
proc.SetEncryptor(cereal.EncryptAES, enc)

Built-in algorithms:

TypeConstants
EncryptionEncryptAES, EncryptRSA, EncryptEnvelope
HashingHashSHA256, HashSHA512, HashArgon2, HashBcrypt
MaskingMaskEmail, MaskSSN, MaskPhone, MaskCard, MaskIP, MaskUUID, MaskIBAN, MaskName

Configuration via Setters

Processors are configured with setter methods:

proc, _ := cereal.NewProcessor[User]()

// Configure encryption
enc, _ := cereal.AES(key)
proc.SetEncryptor(cereal.EncryptAES, enc)

// Configure custom hasher
proc.SetHasher(cereal.HashArgon2, customArgon2)

// Configure custom masker
proc.SetMasker(cereal.MaskEmail, customEmailMasker)

Setters return the processor for chaining:

proc.SetEncryptor(cereal.EncryptAES, aesEnc).
    SetEncryptor(cereal.EncryptRSA, rsaEnc).
    SetHasher(cereal.HashArgon2, argon2)

Validation

Validation runs automatically on the first operation. If a required handler is missing, the operation returns an error:

proc, _ := cereal.NewProcessor[User]()
// Forgot to configure encryptor...

result, err := proc.Store(ctx, user)
// err: missing encryptor for algorithm "aes" (field Email)

For fail-fast behavior at startup, call Validate() explicitly:

proc, _ := cereal.NewProcessor[User]()
proc.SetEncryptor(cereal.EncryptAES, enc)

if err := proc.Validate(); err != nil {
    log.Fatal(err) // Catch configuration errors immediately
}

Validation checks that all algorithms referenced in tags have registered handlers. Types implementing override interfaces (Encryptable, Decryptable, Hashable, Maskable) bypass validation for their respective transform types.

Sentinel Integration

The processor uses sentinel for struct metadata. Field plans are built once per type and cached globally:

// First call for User builds and caches field plans
proc1, _ := cereal.NewProcessor[User]()

// Subsequent calls reuse cached plans (no reflection overhead)
proc2, _ := cereal.NewProcessor[User]()

// Each processor has independent configuration
proc1.SetEncryptor(cereal.EncryptAES, enc1)
proc2.SetEncryptor(cereal.EncryptAES, enc2)

This design provides:

  • Shared plans: Reflection happens once per type, regardless of how many processors are created
  • Independent state: Each processor maintains its own encryptors, hashers, and maskers
  • Safe key rotation: Configure different keys per processor without affecting others

Test Isolation

For tests that need to verify plan building, ResetPlansCache() clears the internal cache:

func TestSomething(t *testing.T) {
    cereal.ResetPlansCache()
    defer cereal.ResetPlansCache()

    proc, _ := cereal.NewProcessor[User]()
    // ...
}

Most tests don't need this—processors with independent state can be created freely.

Override Interfaces

For performance-critical paths, implement these interfaces to bypass reflection:

type Hashable interface {
    Hash(hashers map[HashAlgo]Hasher) error
}

type Encryptable interface {
    Encrypt(encryptors map[EncryptAlgo]Encryptor) error
}

type Decryptable interface {
    Decrypt(encryptors map[EncryptAlgo]Encryptor) error
}

type Maskable interface {
    Mask(maskers map[MaskType]Masker) error
}

type Redactable interface {
    Redact() error
}

When implemented, the processor calls these methods instead of using reflection.

See Override Interfaces for implementation guidance.

Context Propagation

All operations accept context.Context:

proc.Receive(ctx, obj)
proc.Load(ctx, obj)
proc.Store(ctx, obj)
proc.Send(ctx, obj)

Context flows through to signal emission for tracing integration. Pass your request context to correlate operations with traces.

Thread Safety

Processors are safe for concurrent use. Multiple goroutines can call Receive, Load, Store, and Send simultaneously on the same processor instance.

Configuration methods (SetEncryptor, SetHasher, SetMasker) use internal synchronization and can be called during operation, though typically configuration happens at startup.

proc, _ := cereal.NewProcessor[User]()
proc.SetEncryptor(cereal.EncryptAES, enc)

// Safe: concurrent operations on same processor
go func() { proc.Store(ctx, user1) }()
go func() { proc.Store(ctx, user2) }()
go func() { proc.Load(ctx, user3) }()

Nested Struct Support

Transforms apply to nested structs:

type Address struct {
    Street string `json:"street"`
    City   string `json:"city" send.redact:"[HIDDEN]"`
}

type User struct {
    Name    string  `json:"name" send.mask:"name"`
    Address Address `json:"address"` // City will be redacted on Send
}

Both direct fields and nested struct fields are processed.