zoobzio January 5, 2025 Edit this page

Interfaces

Custom serialization with Atomizable and Deatomizable.

Overview

By default, atom uses reflection to convert structs to atoms. You can bypass reflection by implementing the Atomizable and Deatomizable interfaces:

type Atomizable interface {
    Atomize(*Atom)
}

type Deatomizable interface {
    Deatomize(*Atom) error
}

Why Use Interfaces?

  1. Performance: Eliminate reflection overhead
  2. Custom Logic: Transform data during serialization
  3. Computed Fields: Derive values not stored in the struct
  4. Backwards Compatibility: Handle schema migrations

Implementing Atomizable

type User struct {
    FirstName string
    LastName  string
    BirthYear int64
}

func (u *User) Atomize(a *Atom) {
    a.Strings["FirstName"] = u.FirstName
    a.Strings["LastName"] = u.LastName
    a.Ints["BirthYear"] = u.BirthYear

    // Computed field
    a.Strings["FullName"] = u.FirstName + " " + u.LastName
}

Usage

atomizer, _ := atom.Use[User]()
user := &User{FirstName: "Alice", LastName: "Smith", BirthYear: 1990}

atom := atomizer.Atomize(user)
// Uses User.Atomize() instead of reflection

fmt.Println(atom.Strings["FullName"]) // "Alice Smith"

Implementing Deatomizable

func (u *User) Deatomize(a *Atom) error {
    u.FirstName = a.Strings["FirstName"]
    u.LastName = a.Strings["LastName"]
    u.BirthYear = a.Ints["BirthYear"]

    // Validation
    if u.BirthYear < 1900 || u.BirthYear > 2100 {
        return fmt.Errorf("invalid birth year: %d", u.BirthYear)
    }

    return nil
}

Usage

atom := &Atom{
    Strings: map[string]string{"FirstName": "Bob", "LastName": "Jones"},
    Ints:    map[string]int64{"BirthYear": 1985},
}

user, err := atomizer.Deatomize(atom)
// Uses User.Deatomize() instead of reflection

Partial Implementation

You can implement only one interface:

Atomizable Only

type Metrics struct {
    RequestCount int64
    ErrorCount   int64
    // Internal, not serialized
    lastUpdate time.Time
}

func (m *Metrics) Atomize(a *Atom) {
    a.Ints["RequestCount"] = m.RequestCount
    a.Ints["ErrorCount"] = m.ErrorCount
    // Computed
    a.Floats["ErrorRate"] = float64(m.ErrorCount) / float64(m.RequestCount)
}

// No Deatomize - uses reflection

Deatomizable Only

type Config struct {
    Host string
    Port int64
}

// No Atomize - uses reflection

func (c *Config) Deatomize(a *Atom) error {
    c.Host = a.Strings["Host"]
    c.Port = a.Ints["Port"]

    // Apply defaults
    if c.Host == "" {
        c.Host = "localhost"
    }
    if c.Port == 0 {
        c.Port = 8080
    }

    return nil
}

Handling Nested Types

For nested structs, call their methods:

type Order struct {
    ID    int64
    Items []OrderItem
}

type OrderItem struct {
    ProductID int64
    Quantity  int64
}

func (o *Order) Atomize(a *Atom) {
    a.Ints["ID"] = o.ID

    items := make([]Atom, len(o.Items))
    for i, item := range o.Items {
        itemAtom := Atom{Ints: make(map[string]int64)}
        item.Atomize(&itemAtom)
        items[i] = itemAtom
    }
    a.NestedSlices["Items"] = items
}

func (item *OrderItem) Atomize(a *Atom) {
    a.Ints["ProductID"] = item.ProductID
    a.Ints["Quantity"] = item.Quantity
}

Schema Migration

Handle old and new field names:

type User struct {
    Email string // Was "EmailAddress" in v1
}

func (u *User) Deatomize(a *Atom) error {
    // Try new name first
    if email, ok := a.Strings["Email"]; ok {
        u.Email = email
    } else if email, ok := a.Strings["EmailAddress"]; ok {
        // Fall back to old name
        u.Email = email
    }
    return nil
}

func (u *User) Atomize(a *Atom) {
    // Always write new name
    a.Strings["Email"] = u.Email
}

Encryption/Encoding

Transform sensitive data:

type Secret struct {
    Data []byte
}

func (s *Secret) Atomize(a *Atom) {
    // Encrypt before storing
    encrypted := encrypt(s.Data)
    a.Bytes["Data"] = encrypted
}

func (s *Secret) Deatomize(a *Atom) error {
    encrypted := a.Bytes["Data"]
    // Decrypt after loading
    decrypted, err := decrypt(encrypted)
    if err != nil {
        return err
    }
    s.Data = decrypted
    return nil
}

Validation

Validate during deatomization:

type User struct {
    Age   int64
    Email string
}

func (u *User) Deatomize(a *Atom) error {
    u.Age = a.Ints["Age"]
    u.Email = a.Strings["Email"]

    // Validation
    if u.Age < 0 || u.Age > 150 {
        return fmt.Errorf("invalid age: %d", u.Age)
    }
    if !strings.Contains(u.Email, "@") {
        return fmt.Errorf("invalid email: %s", u.Email)
    }

    return nil
}

Code Generation

The interface pattern enables code generation to eliminate reflection:

// Generated by atomgen
func (u *User) Atomize(a *Atom) {
    a.Strings["FirstName"] = u.FirstName
    a.Strings["LastName"] = u.LastName
    a.Ints["Age"] = u.Age
    a.Bools["Active"] = u.Active
}

func (u *User) Deatomize(a *Atom) error {
    u.FirstName = a.Strings["FirstName"]
    u.LastName = a.Strings["LastName"]
    u.Age = a.Ints["Age"]
    u.Active = a.Bools["Active"]
    return nil
}

See Code Generation Cookbook for details.

Best Practices

Keep It Simple

// Good - straightforward mapping
func (u *User) Atomize(a *Atom) {
    a.Strings["Name"] = u.Name
    a.Ints["Age"] = u.Age
}

// Avoid - complex logic in serialization
func (u *User) Atomize(a *Atom) {
    // Don't do heavy computation here
    a.Strings["Name"] = expensiveTransform(u.Name)
}

Match the Structure

// Good - mirrors struct fields
func (u *User) Atomize(a *Atom) {
    a.Strings["Name"] = u.Name
    a.Strings["Email"] = u.Email
}

// Confusing - different structure
func (u *User) Atomize(a *Atom) {
    a.Strings["user_name"] = u.Name      // Different key
    a.Ints["email_hash"] = hash(u.Email) // Different type/value
}

Document Migrations

func (u *User) Deatomize(a *Atom) error {
    // Migration: "name" was renamed to "full_name" in v2.0
    if name, ok := a.Strings["full_name"]; ok {
        u.Name = name
    } else {
        u.Name = a.Strings["name"] // Legacy
    }
    return nil
}

Next Steps