Skip to main content

Settings Service

The settings service provides schema-driven, encrypted configuration management with rich validation rules and UI hints for automated form generation.

Overview

Based on pkg/v2/application/settings/, the settings service provides:

  • Schema Definition: Type-safe settings with validation rules
  • AES-256-GCM Encryption: Transparent encryption for sensitive data
  • Rich Validation: Type checking, length, patterns, enums, min/max
  • UI Hints: Schema-driven form generation metadata
  • Multi-Tenant Isolation: App-based partitioning with audit trail
  • Distributed Caching: In-memory cache with TTL and event-driven invalidation
  • Automatic Masking: Sensitive values masked in public APIs

Service Architecture

Based on pkg/v2/application/settings/settings_service.go:

Service Interface

type Service interface {
RegisterSettings(ctx context.Context, cmd *RegisterSettingsCommand) (*RegisterSettingsResponse, error)
UpdateSettings(ctx context.Context, cmd *UpdateSettingsCommand) (*UpdateSettingsResponse, error)
GetSettings(ctx context.Context, query *GetSettingsQuery) (*GetSettingsResponse, error)
GetSettingValue(ctx context.Context, query *GetSettingValueQuery) (*GetSettingValueResponse, error)
ValidateRequiredSettings(ctx context.Context, appID uuid.UUID) error
DeleteSettings(ctx context.Context, cmd *DeleteSettingsCommand) error
GetSettingsInternal(ctx context.Context, query *GetSettingsInternalQuery) (*GetSettingsResponse, error)
}

Core Components

type settingsService struct {
settingsRepo repository.SettingsRepository
appRepo repository.AppRepository
eventBus event.Bus
cache *SettingsCache
logger telemetry.Logger
}

Schema Definition and Validation

Based on pkg/v2/domain/settings/setting_definition.go:

SettingDefinition Structure

type SettingDefinition struct {
ID uuid.UUID
AppID uuid.UUID
Key string
DataType SettingDataType
Required bool
DefaultValue interface{}
Validation *ValidationRules
Description string
Sensitive bool
UIHints *UIHints
CreatedAt time.Time
}

Builder Pattern

def := settings.NewSettingDefinition(appID, "api_key", settings.DataTypeStringRequired).
WithDefaultValue("").
WithValidation(&settings.ValidationRules{
MinLength: ptr(8),
MaxLength: ptr(64),
Pattern: ptr("^[A-Za-z0-9_-]+$"),
}).
WithDescription("API key for external service").
WithSensitive(true).
WithUIHints(settings.NewUIHints().
WithInputType("password").
WithPlaceholder("Enter your API key").
WithHelpText("Contact support to obtain an API key"),
)

Validation Lifecycle

From setting_definition.go:71-102:

func (d *SettingDefinition) Validate() error {
// 1. Key validation
// 2. DataType validation
// 3. Required field enforcement
// 4. Regex pattern validation
// 5. Default value validation against rules
}

Data Types and Type System

Based on pkg/v2/domain/settings/data_type.go:

Complete Type System

const (
DataTypeString = "STRING"
DataTypeStringRequired = "STRING_REQUIRED"
DataTypeInt = "INT"
DataTypeIntRequired = "INT_REQUIRED"
DataTypeFloat = "FLOAT"
DataTypeFloatRequired = "FLOAT_REQUIRED"
DataTypeBool = "BOOL"
DataTypeBoolRequired = "BOOL_REQUIRED"
DataTypeStringArray = "STRING_ARRAY"
DataTypeIntArray = "INT_ARRAY"
DataTypeFloatArray = "FLOAT_ARRAY"
DataTypeBoolArray = "BOOL_ARRAY"
DataTypeJSON = "JSON"
DataTypeJSONRequired = "JSON_REQUIRED"
DataTypeKVPairArray = "KVPAIR_ARRAY"
)

Type Helper Methods

// Returns true for *_REQUIRED types
IsRequired() bool

// Strips REQUIRED suffix
BaseType() string

// Validates type is in allowed set
IsValid() bool

Validation Rules

Based on pkg/v2/domain/settings/validation_rules.go:

Rules Structure

type ValidationRules struct {
MinLength *int // String minimum length
MaxLength *int // String maximum length
Min *int64 // Number minimum value
Max *int64 // Number maximum value
Pattern *string // Regex pattern for strings
Enum []string // Allowed values
}

Pattern Validation

From validation_rules.go:56-65:

  • Compiles and validates regex patterns at definition time
  • Prevents invalid patterns from being stored

Validator Implementation

Based on pkg/v2/domain/settings/validator.go:

Three-Phase Validation:

  1. Type Validation (lines 39-162):

    • Handles nullable types
    • JSON number coercion to integers
    • Array element validation
    • KVPairArray validation (expects objects with "key" and "value" fields)
  2. Required Validation (lines 26-29):

    • Checks if required field is empty
    • Uses isEmpty() helper for various types
  3. Rule Application (lines 164-251):

    • String: length, pattern, enum
    • Integer: min/max values
    • Float: min/max values with type coercion

Example Validation

validator := settings.NewSettingValidator(def)
if err := validator.Validate(value); err != nil {
return fmt.Errorf("validation failed: %w", err)
}

Encryption/Decryption

Based on pkg/v2/infrastructure/encryption/aes_encryptor.go:

AES-256-GCM Strategy

type AESEncryptor struct {
cipher cipher.AEAD
}

// Constructor validates key is exactly 32 bytes (256-bit)
func NewAESEncryptor(key []byte) (*AESEncryptor, error) {
if len(key) != 32 {
return nil, ErrInvalidKey
}
// Create GCM cipher
}

Encryption Process

From aes_encryptor.go:49-62:

func (e *AESEncryptor) Encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil // No encryption for empty strings
}

// Generate random nonce
nonce := make([]byte, e.cipher.NonceSize())
io.ReadFull(rand.Reader, nonce)

// Seal: nonce + ciphertext
ciphertext := e.cipher.Seal(nonce, nonce, []byte(plaintext), nil)

// Base64 encode for storage
return base64.StdEncoding.EncodeToString(ciphertext), nil
}

Decryption Process

From aes_encryptor.go:64-87:

func (e *AESEncryptor) Decrypt(ciphertextBase64 string) (string, error) {
if ciphertextBase64 == "" {
return "", nil
}

// Decode base64
ciphertext := base64.StdEncoding.DecodeString(ciphertextBase64)

// Extract nonce and ciphertext
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]

// Open (decrypt + verify)
plaintext := e.cipher.Open(nil, nonce, ciphertext, nil)

return string(plaintext), nil
}

Key Features

  • Random Nonce: Same plaintext produces different ciphertexts
  • Authenticated Encryption: GCM mode provides integrity checking
  • Base64 Encoding: Storage-compatible format
  • NoOpEncryptor: Available for non-sensitive settings (testing)

Settings Storage and Retrieval

Based on pkg/v2/infrastructure/storage/postgres/settings_repository.go:

Repository Interface

From pkg/v2/domain/repository/settings.go:

type SettingsRepository interface {
StoreDefinitions(ctx context.Context, definitions [...]) error
GetDefinitions(ctx context.Context, appID uuid.UUID) ([...], error)
GetDefinition(ctx context.Context, appID uuid.UUID, key string) ([...], error)

StoreValue(ctx context.Context, value [...]) error
StoreValues(ctx context.Context, values [...]) error
GetValue(ctx context.Context, appID uuid.UUID, key string) ([...], error)
GetValues(ctx context.Context, appID uuid.UUID) ([...], error)

LogChange(ctx context.Context, appID uuid.UUID, key, oldValue, newValue, changedBy) error
DeleteSettings(ctx context.Context, appID uuid.UUID) error
}

Definition Storage

From settings_repository.go:34-142:

INSERT INTO appserver.app_setting_definitions
(id, app_id, key, data_type, required, default_value, validation_rules,
description, sensitive, ui_hints, created_at)

Sensitive Default Values: Encrypted before storage

if def.Sensitive {
encrypted, _ := r.encryptor.Encrypt(string(jsonBytes))
// Wrap as JSON string for JSONB storage
}

Value Storage with Upsert

From settings_repository.go:205-280:

INSERT INTO appserver.app_setting_values (id, app_id, key, value, updated_by, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (app_id, key) DO UPDATE SET
value = EXCLUDED.value,
updated_by = EXCLUDED.updated_by,
updated_at = EXCLUDED.updated_at

Automatic Encryption Logic:

  1. Get definitions to identify sensitive fields
  2. Only encrypt values marked as sensitive
  3. Wrap encrypted base64 as JSON string for JSONB storage
  4. JSON marshaling → encryption → JSON wrapping → storage

Value Retrieval with Decryption

From settings_repository.go:282-340:

func (r *SettingsRepository) GetValue(ctx, appID, key) {
// Get definition to check if sensitive
def, _ := r.GetDefinition(ctx, appID, key)

if def.Sensitive {
// Unmarshal JSON wrapper
var encryptedStr string
json.Unmarshal([]byte(valueStr), &encryptedStr)

// Decrypt
decrypted, _ := r.encryptor.Decrypt(encryptedStr)
valueStr = decrypted
}

// Deserialize JSON value
json.Unmarshal([]byte(valueStr), &val.Value)
}

Audit Logging

From settings_repository.go:420-500:

  • Logs all setting changes with old/new values
  • Encrypts sensitive values in audit log
  • Stores in app_setting_changes table with timestamp and changer ID

Schema-Driven Form Generation

Based on pkg/v2/domain/settings/ui_hints.go:

UIHints Structure

type UIHints struct {
InputType *string // text, password, textarea, select, number
Placeholder *string // Placeholder text
HelpText *string // Help text
Multiline *bool // For text fields
Step *int // For number inputs
Options []string // For select inputs
}

Builder Pattern

hints := settings.NewUIHints().
WithInputType("password").
WithPlaceholder("Enter API key").
WithHelpText("Your secret API key").
WithOptions([]string{"option1", "option2"})

Protobuf Conversion

From settings_service.go:612-636:

if pbDef.UiHints != nil {
hints := settings.NewUIHints()

if pbDef.UiHints.InputType != nil {
hints.WithInputType(*pbDef.UiHints.InputType)
}
if pbDef.UiHints.Options != nil {
hints.WithOptions(pbDef.UiHints.Options)
}

def.WithUIHints(hints)
}

Frontend Integration

The frontend can automatically generate forms using hints:

// Example auto-generated form
{
key: "api_key",
type: "password",
placeholder: "Enter your API key",
helpText: "Contact support to obtain an API key",
required: true,
validation: {
minLength: 8,
maxLength: 64,
pattern: "^[A-Za-z0-9_-]+$"
}
}

Multi-Tenant Isolation

Isolation Strategy

1. App-Based Partitioning:

  • All methods require appID uuid.UUID parameter
  • No cross-app data access possible at service level
  • Repository queries filtered by WHERE app_id = $1

2. Example Isolation (settings_service.go:298):

definitions, err := s.settingsRepo.GetDefinitions(ctx, query.AppID)

3. Cache Isolation (cache.go:27):

type SettingsCache struct {
entries map[uuid.UUID]*CacheEntry // Keyed by AppID
}

4. Event-Based Invalidation (settings_service.go:46-61):

  • Listens to EventTypeSettingsUpdated with AppID in payload
  • Invalidates only specific app's cache: svc.cache.Invalidate(payload.AppID)

5. Audit Trail Isolation (settings_repository.go:480):

INSERT INTO appserver.app_setting_changes
(id, app_id, key, old_value, new_value, changed_by, changed_at)

Distributed Caching

Based on pkg/v2/application/settings/cache.go:

Cache Design

type SettingsCache struct {
mu sync.RWMutex
entries map[uuid.UUID]*CacheEntry
ttl time.Duration
}

type CacheEntry struct {
Definitions []*settings.SettingDefinition
Values []*settings.SettingValue
CachedAt time.Time
}

Cache Operations

1. Get with Expiration Check (lines 44-60):

func (c *SettingsCache) Get(appID uuid.UUID) (*CacheEntry, bool) {
entry, exists := c.entries[appID]
if !exists {
return nil, false
}

// Check if expired
if entry.IsExpired(c.ttl) {
return nil, false // Treat expired as miss
}

return entry, true
}

2. Automatic Cleanup Loop (lines 91-110):

  • Background goroutine runs cleanup at ttl/2 intervals
  • Removes all expired entries
  • Prevents memory leaks

3. Cache Hit Path (settings_service.go:280-290):

if entry, found := s.cache.Get(query.AppID); found {
// Mask sensitive values before returning
maskedValues := s.maskSensitiveValues(entry.Definitions, entry.Values)
return &GetSettingsResponse{
Definitions: entry.Definitions,
Values: maskedValues,
}, nil
}

4. Cache Population (settings_service.go:317-319):

// Store unmasked values in cache (for internal use)
s.cache.Set(query.AppID, definitions, values)

5. Invalidation Strategy:

  • Event-driven: Subscribe to EventTypeSettingsUpdated and EventTypeSettingsDeleted
  • Manual: Called after UpdateSettings() and DeleteSettings()
  • Ensures consistency across distributed instances

Sensitive Data Masking

Based on settings_service.go:682-720:

Masking Implementation

func (s *settingsService) maskSensitiveValues(
definitions []*settings.SettingDefinition,
values []*settings.SettingValue,
) []*settings.SettingValue {
// Build map of sensitive keys
sensitiveKeys := make(map[string]bool)
for _, def := range definitions {
if def.Sensitive {
sensitiveKeys[def.Key] = true
}
}

// Clone and mask values
masked := make([]*settings.SettingValue, len(values))
for i, val := range values {
if sensitiveKeys[val.Key] {
masked[i].Value = "********"
masked[i].IsMasked = true
} else {
masked[i].Value = val.Value
masked[i].IsMasked = false
}
}

return masked
}

Masking Applied At

  1. Public API response (GetSettings())
  2. Per-setting response (GetSettingValueResponse)
  3. Cache hits (re-masked each time)

Internal Access Override

From settings_service.go:467-553:

func (s *settingsService) GetSettingsInternal(...) {
// Authorization: Only allow service, admin, worker types
authorizedTypes := map[string]bool{
"service": true,
"admin": true,
"worker": true,
}

if !authorizedTypes[query.RequesterType] {
return nil, fmt.Errorf("unauthorized...")
}

// Audit log sensitive access
if query.IncludeSensitive {
s.logger.Warn("sensitive settings accessed by internal service",
telemetry.String("requester_type", query.RequesterType),
telemetry.Int("sensitive_count", sensitiveCount),
)
}

// Return unmasked if requested
if query.IncludeSensitive {
// Return actual decrypted values from repository
}
}

Domain Events

Based on pkg/v2/domain/event/settings_events.go:

Event Types

const (
EventTypeSettingsRegistered = "settings.registered"
EventTypeSettingsUpdated = "settings.updated"
EventTypeSettingsValidationFailed = "settings.validation_failed"
EventTypeSettingsDeleted = "settings.deleted"
)

Event Payloads

1. SettingsRegisteredPayload:

{
AppID: uuid.UUID
AppName: string
DefinitionCount: int
}

2. SettingsUpdatedPayload:

{
AppID: uuid.UUID
AppName: string
ChangedKeys: []string
UpdatedBy: *uuid.UUID
}

3. SettingsValidationFailedPayload:

{
AppID: uuid.UUID
AppName: string
Errors: []ValidationError // With Key, Message
}

4. SettingsDeletedPayload:

{
AppID: uuid.UUID
AppName: string
}

Event Publishing

From settings_service.go:254-261:

for _, evt := range agg.GetChanges() {
if err := s.eventBus.Publish(ctx, evt); err != nil {
s.logger.Warn("failed to publish event",
telemetry.Error(err),
telemetry.String("event_type", evt.EventType()),
)
}
}

Error Handling

Based on pkg/v2/application/settings/errors.go:

ValidationError Type

From errors.go:17-40:

type ValidationError struct {
Errors []SettingError
}

type SettingError struct {
Key string
Message string
Code string
}

Error Flow

From settings_service.go:195-222:

if err := agg.UpdateSettings(ctx, cmd.Settings, cmd.UpdatedBy); err != nil {
if errors.Is(err, settings.ErrValidationFailed) {
// Extract validation errors from aggregate
validationErrors := agg.GetValidationErrors()
var settingErrors []SettingError
for _, ve := range validationErrors {
settingErrors = append(settingErrors, SettingError{
Key: ve.Key,
Message: ve.Message,
Code: "VALIDATION_ERROR",
})
}

// Return structured error for gRPC layer
return nil, &ValidationError{
Errors: settingErrors,
}
}
}

Usage Example

Registering Settings Schema

schema := &pb.SettingsSchema{
Definitions: []*pb.SettingDefinition{
{
Key: "database_url",
DataType: "STRING_REQUIRED",
Description: "PostgreSQL connection URL",
Sensitive: true,
UiHints: &pb.UIHints{
InputType: ptr("password"),
Placeholder: ptr("postgresql://user:pass@host:5432/db"),
HelpText: ptr("Full database connection URL"),
},
},
{
Key: "max_connections",
DataType: "INT",
DefaultValue: "10",
Description: "Maximum database connections",
ValidationRules: &pb.ValidationRules{
Min: ptr(int64(1)),
Max: ptr(int64(100)),
},
UiHints: &pb.UIHints{
InputType: ptr("number"),
Step: ptr(1),
},
},
},
}

cmd := &RegisterSettingsCommand{
AppID: appID,
AppName: "my-app",
SettingsSchema: schema,
}

resp, err := settingsService.RegisterSettings(ctx, cmd)

Updating Settings

cmd := &UpdateSettingsCommand{
AppID: appID,
AppName: "my-app",
Settings: map[string]interface{}{
"database_url": "postgresql://user:pass@localhost:5432/mydb",
"max_connections": 25,
},
UpdatedBy: &userID,
}

resp, err := settingsService.UpdateSettings(ctx, cmd)

Retrieving Settings

query := &GetSettingsQuery{
AppID: appID,
}

resp, err := settingsService.GetSettings(ctx, query)
// resp.Values will have sensitive values masked ("********")

Summary Table

AspectTechnologyDetails
EncryptionAES-256-GCMRandom nonce per message, Base64 encoded
CachingIn-memory with TTLBackground cleanup at TTL/2
ValidationCustom validatorType, required, length, pattern, enum, min/max
StoragePostgreSQL JSONBAtomic transactions, upsert on conflict
MaskingField-level"********" for sensitive, IsMasked flag
TenancyApp-basedAll queries filtered by AppID
EventsEvent sourcing4 event types with structured payloads
UI HintsSchema-drivenInputType, placeholder, help text, options

Key Files

FilePurpose
settings_service.goService orchestration and public API
setting_definition.goSchema definition with builder pattern
validator.goThree-phase validation logic
validation_rules.goRule structures and pattern validation
ui_hints.goForm generation metadata
cache.goIn-memory cache with TTL
aes_encryptor.goAES-256-GCM encryption implementation
settings_repository.goPostgreSQL persistence with encryption
settings_aggregate.goEvent sourcing aggregate
settings_events.goDomain event definitions