Purpose
This guide provides comprehensive guidance on value transformations in LutaML Model, helping you choose the right approach for your transformation needs.
Value transformations are operations that convert data between different representations while preserving or deriving the underlying information. This guide distinguishes between value-level and model-level transformations and provides a decision framework for implementation.
Concepts
Value vs Model Distinction
Understanding the difference between values and models is crucial for choosing the right transformation approach.
- Value (Atomic)
-
The smallest representation in a serialization format that cannot be decomposed further within that format.
Characteristics of Values-
Atomic - Cannot be broken down into smaller parts in the serialization format
-
Self-contained - Represents a complete piece of information
-
Format-specific - May have different representations in different formats
-
"20241225"- A date string in YYYYMMDD format -
1234- An integer -
true- A boolean -
"RGB(255,128,0)"- A color specification -
"2024-12-25T10:30:00Z"- An ISO8601 datetime
-
- Model (Composite)
-
A structure containing multiple attributes or components that can be accessed individually.
Characteristics of Models-
Composite - Contains multiple distinct attributes
-
Mappable - Can use mapping rules to transform structure
-
Decomposable - Components can be accessed and mapped separately
-
{year: 2024, month: 12, day: 25}- Date components as separate attributes -
{red: 255, green: 128, blue: 0}- Color components -
{hour: 10, minute: 30, second: 0}- Time components
-
The fundamental difference is whether the data can be mapped (model) or must be transformed (value).
-
Mapping - Structural reorganization without computation
-
Transformation - Computational conversion or derivation
Example: Converting "20241225" to "25122024" requires transformation (string manipulation), while converting {year: 2024, month: 12, day: 25} to {day: 25, month: 12, year: 2024} only requires mapping.
Transformation Types
Value transformations fall into two main categories:
Format Conversion Transformations
Purpose: Convert the same information between different representations.
Characteristics: * Information is preserved * Typically bidirectional * No calculation required (only format manipulation)
| From Format | To Format | Transformation Type |
|---|---|---|
YYYYMMDD ( | DDMMYYYY ( | String reformatting |
ISO8601 ( | YYYYMMDD ( | Date component rearrangement |
Hex color ( | RGB notation ( | Numeric base conversion |
Unix timestamp ( | ISO8601 ( | Time representation conversion |
Calculated Value Transformations
Purpose: Derive new information through computation.
Characteristics: * Information may be derived or aggregated * May be unidirectional or bidirectional * Requires calculation or algorithmic processing
| From Value | To Value | Calculation Required |
|---|---|---|
YYYYMMDD ( | YYYYWWDD ( | ISO week number calculation |
Celsius ( | Fahrenheit ( | Temperature formula: F = C × 9/5 + 32 |
Distance + Speed | Duration | time = distance / speed |
Date components | Day of year | Days since January 1st |
Transformation Approaches
LutaML Model provides three distinct approaches for value transformations, each suited for different scenarios.
Approach 1: Custom Value Type Classes
Overview
Custom Value Type classes provide the most powerful and reusable transformation mechanism by creating dedicated type classes that inherit from Lutaml::Model::Type::Value.
Structure
class CustomValueType < Lutaml::Model::Type::Value
# REQUIRED: Core conversion methods
def self.cast(value)
# Convert external/string value to internal Ruby object
# This is called when assigning values to attributes
end
def self.serialize(value)
# Convert internal Ruby object to generic serialization string
# Used as fallback when format-specific methods not defined
end
# OPTIONAL: Format-specific deserialization
def self.from_xml(value)
# Parse XML string representation to internal object
end
def self.from_json(value)
# Parse JSON string representation to internal object
end
def self.from_yaml(value)
# Parse YAML string representation to internal object
end
# OPTIONAL: Format-specific serialization
def to_xml
# Serialize internal object to XML string
end
def to_json
# Serialize internal object to JSON string
end
def to_yaml
# Serialize internal object to YAML string
end
endWhen to Use
Use Custom Value Type classes when:
-
✅ Bidirectional transformations are needed
-
✅ Format-specific representations are required
-
✅ Logic will be reused across multiple attributes or models
-
✅ Complex parsing or serialization logic is involved
-
✅ Type safety and encapsulation are important
-
✅ Custom validation rules are needed
Advantages
-
Reusable - Define once, use across many attributes
-
Type-safe - Encapsulates all transformation logic in one place
-
Format-aware - Different logic per serialization format
-
Testable - Easy to unit test in isolation
-
Maintainable - Centralized logic, easier to modify
Complete Example: Bidirectional Date Formats
# Supports different date formats in different serialization formats
class MultiFormatDate < Lutaml::Model::Type::Date
# Core conversion: any input to Date object
def self.cast(value)
case value
when ::Date then value
when ::String then parse_any_format(value)
else
super # Delegate to parent class
end
end
# Default serialization
def self.serialize(value)
value&.iso8601
end
# XML uses YYYYMMDD format
def self.from_xml(value)
return nil if value.nil? || value.empty?
year = value[0..3].to_i
month = value[4..5].to_i
day = value[6..7].to_i
::Date.new(year, month, day)
rescue ArgumentError
nil
end
def to_xml
value&.strftime("%Y%m%d")
end
# JSON uses DDMMYYYY format
def self.from_json(value)
return nil if value.nil? || value.empty?
day = value[0..1].to_i
month = value[2..3].to_i
year = value[4..7].to_i
::Date.new(year, month, day)
rescue ArgumentError
nil
end
def to_json
value&.strftime("%d%m%Y")
end
# YAML uses standard ISO8601
def self.from_yaml(value)
::Date.parse(value.to_s)
rescue ArgumentError
nil
end
def to_yaml
value&.iso8601
end
private
def self.parse_any_format(str)
# Try different formats intelligently
from_xml(str) || from_json(str) || ::Date.parse(str)
rescue
nil
end
end
# Usage in a model
class Event < Lutaml::Model::Serializable
attribute :event_date, MultiFormatDate
xml do
root "event"
map_element "eventDate", to: :event_date
end
json do
map "eventDate", to: :event_date
end
yaml do
map "eventDate", to: :event_date
end
endevent = Event.new(event_date: Date.new(2024, 12, 25))
# XML serialization uses YYYYMMDD
xml = event.to_xml
# => <event><eventDate>20241225</eventDate></event>
# JSON serialization uses DDMMYYYY
json = event.to_json
# => {"eventDate":"25122024"}
# YAML serialization uses ISO8601
yaml = event.to_yaml
# => ---
# => eventDate: '2024-12-25'
# Round-trip: XML → Object → JSON
event_from_xml = Event.from_xml(xml)
event_from_xml.to_json
# => {"eventDate":"25122024"}
# Round-trip: JSON → Object → XML
event_from_json = Event.from_json(json)
event_from_json.to_xml
# => <event><eventDate>20241225</eventDate></event>
# All preserve the same date
event_from_xml.event_date == event_from_json.event_date
# => trueApproach 2: Attribute-Level Transform Procs
Overview
Attribute-level transform procs apply the same transformation logic across all serialization formats, defined directly on the attribute.
Structure
class MyModel < Lutaml::Model::Serializable
attribute :attr_name, :type, transform: {
export: ->(value) {
# Transform model value before serialization
# Applied to ALL formats (XML, JSON, YAML, etc.)
},
import: ->(value) {
# Transform serialized value after deserialization
# Applied to ALL formats (XML, JSON, YAML, etc.)
}
}
endWhen to Use
Use Attribute-Level Transform Procs when:
-
✅ Same transformation applies to ALL serialization formats
-
✅ Logic is simple and inline
-
✅ Specific to one attribute in one model
-
✅ No format-specific behavior needed
-
✅ Quick, non-reusable transformations
Transformation Flow
Deserialization (Import):
Serialization Format Value (any format)
↓
Attribute Transform: import
↓
Model Attribute Value
Serialization (Export):
Model Attribute Value
↓
Attribute Transform: export
↓
Serialization Format Value (any format)Example: Uniform Date Format
class Document < Lutaml::Model::Serializable
# Transform applies to ALL formats (XML, JSON, YAML)
attribute :publication_date, :date, transform: {
export: ->(date) {
# ALL formats will get YYYYMMDD string
date&.strftime("%Y%m%d")
},
import: ->(str) {
# Parse YYYYMMDD from ALL formats
return nil if str.nil? || str.empty?
year = str[0..3].to_i
month = str[4..5].to_i
day = str[6..7].to_i
Date.new(year, month, day)
}
}
xml do
root "document"
map_element "pubDate", to: :publication_date
end
json do
map "pubDate", to: :publication_date
end
enddoc = Document.new(publication_date: Date.new(2024, 12, 25))
# All formats use the same transformation
doc.to_xml
# => <document><pubDate>20241225</pubDate></document>
doc.to_json
# => {"pubDate":"20241225"}
doc.to_yaml
# => ---
# => pubDate: '20241225'Approach 3: Mapping-Level Transform Procs
Overview
Mapping-level transform procs apply format-specific transformation logic, defined within each serialization format’s mapping block.
Structure
class MyModel < Lutaml::Model::Serializable
attribute :attr_name, :type
xml do
map_element "name", to: :attr_name, transform: {
export: ->(value) { xml_specific_export },
import: ->(value) { xml_specific_import }
}
end
json do
map "name", to: :attr_name, transform: {
export: ->(value) { json_specific_export },
import: ->(value) { json_specific_import }
}
end
endWhen to Use
Use Mapping-Level Transform Procs when:
-
✅ Different transformation per serialization format
-
✅ Format-specific requirements exist
-
✅ One-off, non-reusable transformation
-
✅ Quick inline modification needed
-
✅ Combined with attribute-level transforms
Transformation Flow with Precedence
Deserialization (Import):
Serialization Format Value
↓
Mapping Transform: import (if defined for this format)
↓
Attribute Transform: import (if defined)
↓
Model Attribute Value
Serialization (Export):
Model Attribute Value
↓
Attribute Transform: export (if defined)
↓
Mapping Transform: export (if defined for this format)
↓
Serialization Format ValueExample: Format-Specific Date Representations
class Document < Lutaml::Model::Serializable
attribute :publication_date, :date
# XML wants YYYYMMDD
xml do
root "document"
map_element "pubDate", to: :publication_date, transform: {
export: ->(date) { date&.strftime("%Y%m%d") },
import: ->(str) {
return nil if str.nil? || str.empty?
Date.new(str[0..3].to_i, str[4..5].to_i, str[6..7].to_i)
}
}
end
# JSON wants DDMMYYYY
json do
map "pubDate", to: :publication_date, transform: {
export: ->(date) { date&.strftime("%d%m%Y") },
import: ->(str) {
return nil if str.nil? || str.empty?
Date.new(str[4..7].to_i, str[2..3].to_i, str[0..1].to_i)
}
}
end
# YAML uses standard ISO8601 (no transform needed)
yaml do
map "pubDate", to: :publication_date
# Date type's default serialization is ISO8601
end
enddoc = Document.new(publication_date: Date.new(2024, 12, 25))
# Each format produces different string representation
doc.to_xml
# => <document><pubDate>20241225</pubDate></document>
doc.to_json
# => {"pubDate":"25122024"}
doc.to_yaml
# => ---
# => pubDate: '2024-12-25'
# But all parse back to the same Date object
Document.from_xml(doc.to_xml).publication_date
# => #<Date: 2024-12-25>
Document.from_json(doc.to_json).publication_date
# => #<Date: 2024-12-25>
Document.from_yaml(doc.to_yaml).publication_date
# => #<Date: 2024-12-25>Decision Guide
Decision Matrix
The following matrix helps you choose the appropriate transformation approach:
| Scenario | Best Approach | Bidirectional? | Format-Specific? | Reusable? |
|---|---|---|---|---|
Date: YYYYMMDD ↔ DDMMYYYY across formats | Custom Value Type | ✓ Yes | ✓ Yes | ✓ Yes |
Date: YYYYMMDD → YYYYWWDD (week calculation) | Custom Value Type | ✓ Yes* | ✓ Yes | ✓ Yes |
Always uppercase, all formats | Attribute Transform | ✓ Yes | ✗ No | ✗ No |
Prefix differs by format (JSON: "Dr.", XML: "Prof.") | Mapping Transform | ✓ Yes | ✓ Yes | ✗ No |
Temperature unit conversion | Custom Value Type | ✓ Yes | Maybe | ✓ Yes |
API-specific field formatting | Mapping Transform | ✓ Yes | ✓ Yes | ✗ No |
Normalization (trim, lowercase) | Attribute Transform | ✓ Yes | ✗ No | ✗ No |
*Bidirectional if reverse calculation is possible (e.g., week date can be converted back to calendar date)
Comparison Table
| Feature | Custom Value Type | Attribute Transform | Mapping Transform |
|---|---|---|---|
Scope | Reusable across models | Single attribute | Single attribute in one format |
Format Support | Per-format methods ( | All formats uniformly | Per format individually |
Complexity | Can be complex (full class definition) | Simple inline procs | Simple inline procs |
Testability | Easy (unit testable) | Medium (integration tests) | Medium (integration tests) |
Bidirectionality | Full bidirectional support | Bidirectional via import/export | Bidirectional via import/export |
Type Safety | High (dedicated class) | Medium (proc validation) | Medium (proc validation) |
Performance | Optimal (compiled methods) | Good (proc calls) | Good (proc calls) |
Best For | Complex, reusable logic | Simple, uniform transforms | Format-specific transforms |
Selection Flowchart
Need Value Transformation?
│
▼
┌───────────────────────┐
│ Will logic be reused │
│ across models? │
└───────────────────────┘
│ │
YES │ │ NO
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Format-specific │ │ Same across all │
│ serialization │ │ formats? │
│ needed? │ └──────────────────┘
└──────────────────┘ │ │
│ │ YES │ │ NO
YES │ │ NO ▼ ▼
▼ ▼ ┌─────────┐ ┌────────────┐
┌──────────────┐ ┌──────┐ │Attribute│ │ Mapping │
│ Custom Value │ │Custom│ │Transform│ │ Transform │
│ Type │ │Value │ │ │ │ (per fmt) │
│ │ │Type* │ │ │ │ │
└──────────────┘ └──────┘ └─────────┘ └────────────┘
✓ ✓ ✓ ✓
Bidirectional Maybe just Uniform Format-
format-aware attribute across all specific
reusable transform formats one-off
*Custom Value Type can still be used even without format-specific needs
for better encapsulation and reusabilityAdvanced Patterns
Combining Transformation Approaches
You can combine attribute-level and mapping-level transforms for sophisticated transformations.
Transformation Precedence
When both attribute and mapping transforms are defined, they are applied in sequence:
Deserialization Order: 1. Mapping transform (format-specific) 2. Attribute transform (format-independent)
Serialization Order: 1. Attribute transform (format-independent) 2. Mapping transform (format-specific)
class Product < Lutaml::Model::Serializable
# Attribute-level: Always capitalize
attribute :name, :string, transform: {
export: ->(value) { value.to_s.capitalize },
import: ->(value) { value.to_s.downcase }
}
# Mapping-level: Add format-specific prefixes
json do
map "productName", to: :name, transform: {
export: ->(value) { "JSON:#{value}" },
import: ->(value) { value.gsub("JSON:", "") }
}
end
xml do
root "product"
map_element "name", to: :name, transform: {
export: ->(value) { "XML:#{value}" },
import: ->(value) { value.gsub("XML:", "") }
}
end
end
product = Product.new(name: "laptop")
# Serialization flow:
# 1. Internal: "laptop"
# 2. Attribute export: "Laptop" (capitalize)
# 3. Mapping export: "JSON:Laptop" (add prefix)
product.to_json
# => {"productName":"JSON:Laptop"}
# 4. Mapping export: "XML:Laptop" (add prefix)
product.to_xml
# => <product><name>XML:Laptop</name></product>
# Deserialization flow for JSON:
# Input: {"productName":"JSON:LAPTOP"}
# 1. Mapping import: "LAPTOP" (remove "JSON:")
# 2. Attribute import: "laptop" (downcase)
Product.from_json('{"productName":"JSON:LAPTOP"}').name
# => "laptop"Cross-Format Consistency
When designing transformations, consider how values should behave across different formats.
Strategy 1: Internal Canonical Form
Maintain a canonical internal representation, transform on serialization/deserialization.
# Internal: Always Date object
# External: Format-specific strings
class PortableDate < Lutaml::Model::Type::Date
# Each format has its own representation
def to_xml
value&.strftime("%Y%m%d") # YYYYMMDD
end
def to_json
value&.strftime("%d/%m/%Y") # DD/MM/YYYY
end
def to_yaml
value&.iso8601 # ISO8601
end
endStrategy 2: Format Adaptation
Adapt to external format requirements while maintaining internal consistency.
class APIDate < Lutaml::Model::Serializable
attribute :date, :date
# External API expects ISO8601 in JSON
json do
map "apiDate", to: :date
# Uses Date's default ISO8601 serialization
end
# Internal XML uses compact format
xml do
map_element "date", to: :date, transform: {
export: ->(date) { date&.strftime("%Y%m%d") },
import: ->(str) {
Date.new(str[0..3].to_i, str[4..5].to_i, str[6..7].to_i)
}
}
end
endComplete Examples
Example 1: Bidirectional Date Format Conversion
This example demonstrates converting between YYYYMMDD and DDMMYYYY formats with full bidirectional support.
# Custom Value Type approach (RECOMMENDED for reusability)
class FlexibleDateFormat < Lutaml::Model::Type::Date
# XML uses YYYYMMDD (Year-Month-Day)
def self.from_xml(value)
return nil if value.nil? || value.empty?
year = value[0..3].to_i
month = value[4..5].to_i
day = value[6..7].to_i
::Date.new(year, month, day)
rescue ArgumentError => e
raise Lutaml::Model::InvalidValueError,
"Invalid YYYYMMDD date format: #{value} (#{e.message})"
end
def to_xml
value&.strftime("%Y%m%d")
end
# JSON uses DDMMYYYY (Day-Month-Year)
def self.from_json(value)
return nil if value.nil? || value.empty?
day = value[0..1].to_i
month = value[2..3].to_i
year = value[4..7].to_i
::Date.new(year, month, day)
rescue ArgumentError => e
raise Lutaml::Model::InvalidValueError,
"Invalid DDMMYYYY date format: #{value} (#{e.message})"
end
def to_json
value&.strftime("%d%m%Y")
end
# YAML uses ISO8601 (standard)
def self.from_yaml(value)
::Date.parse(value.to_s)
end
def to_yaml
value&.iso8601
end
end
# Usage in a model
class Event < Lutaml::Model::Serializable
attribute :event_date, FlexibleDateFormat
attribute :name, :string
xml do
root "event"
map_element "eventDate", to: :event_date
map_element "name", to: :name
end
json do
map "eventDate", to: :event_date
map "name", to: :name
end
yaml do
map "eventDate", to: :event_date
map "name", to: :name
end
end# Create event
event = Event.new(
event_date: Date.new(2024, 12, 25),
name: "Christmas"
)
# Serialize to different formats
xml = event.to_xml
# => <event><eventDate>20241225</eventDate><name>Christmas</name></event>
json = event.to_json
# => {"eventDate":"25122024","name":"Christmas"}
yaml = event.to_yaml
# => ---
# => eventDate: '2024-12-25'
# => name: Christmas
# Deserialize from each format
event_from_xml = Event.from_xml(xml)
event_from_xml.event_date
# => #<Date: 2024-12-25>
event_from_json = Event.from_json(json)
event_from_json.event_date
# => #<Date: 2024-12-25>
event_from_yaml = Event.from_yaml(yaml)
event_from_yaml.event_date
# => #<Date: 2024-12-25>
# Cross-format round-trip
Event.from_xml(event.to_xml).to_json
# => {"eventDate":"25122024","name":"Christmas"}
Event.from_json(event.to_json).to_xml
# => <event><eventDate>20241225</eventDate><name>Christmas</name></event>
# All maintain data integrity
event_from_xml == event_from_json
# => trueExample 2: Calculated Transformation (Week-Based Dates)
This example shows transforming calendar dates to ISO week dates, which requires calculation.
# Transforms between calendar dates and ISO week dates
class ISOWeekDate < Lutaml::Model::Type::Date
# Parse standard YYYYMMDD calendar date
def self.from_xml(value)
return nil if value.nil? || value.empty?
year = value[0..3].to_i
month = value[4..5].to_i
day = value[6..7].to_i
::Date.new(year, month, day)
rescue ArgumentError => e
raise Lutaml::Model::InvalidValueError,
"Invalid calendar date: #{value} (#{e.message})"
end
# Serialize to YYYYWWDD format
# YYYY: ISO year (may differ from calendar year near year boundaries)
# WW: ISO week number (01-53)
# DD: Day of week (1=Monday, 7=Sunday)
def to_xml
return nil unless value
# ISO 8601 week date components
year = value.cwyear # Commercial (ISO) year
week = value.cweek.to_s.rjust(2, '0') # Week number with leading zero
day = value.cwday # Day of week (1-7)
"#{year}#{week}#{day}"
end
# Parse YYYYWWDD week date format back to calendar date
def self.from_json(value)
return nil if value.nil? || value.empty?
year = value[0..3].to_i
week = value[4..5].to_i
day = value[6].to_i
# Date.commercial creates date from ISO week-date
::Date.commercial(year, week, day)
rescue ArgumentError => e
raise Lutaml::Model::InvalidValueError,
"Invalid week date: #{value} (#{e.message})"
end
# Output same week format
def to_json
to_xml
end
end
# Usage in a schedule model
class Schedule < Lutaml::Model::Serializable
attribute :week_date, ISOWeekDate
attribute :activity, :string
xml do
root "schedule"
map_element "date", to: :week_date
map_element "activity", to: :activity
end
json do
map "weekDate", to: :week_date
map "activity", to: :activity
end
end# Create schedule with calendar date
schedule = Schedule.new(
week_date: Date.new(2024, 12, 25), # Wednesday
activity: "Team Meeting"
)
# Serializes to week format: YYYYWWDD
xml = schedule.to_xml
# => <schedule>
# <date>20245203</date>
# <activity>Team Meeting</activity>
# </schedule>
# Parse the week date components:
# 2024: Year 2024
# 52: Week 52 of the year
# 03: Day 3 (Wednesday, where Monday=1)
# Deserialize and verify
parsed = Schedule.from_xml(xml)
parsed.week_date
# => #<Date: 2024-12-25>
# Round-trip maintains the date
parsed.week_date == schedule.week_date
# => true
# Edge case: Date near year boundary
new_year = Schedule.new(week_date: Date.new(2024, 12, 30)) # Monday
new_year.to_xml
# => <schedule>
# <date>20240101</date>
# <activity></activity>
# </schedule>
# Note: 2024-12-30 is in ISO week 2024-W01
# (ISO year 2024, week 1, because it's the first Monday of 2025's first week)Example 3: Using Transform Procs for Date Transformations
This example shows the transform proc approach for simpler scenarios.
# Mapping Transform approach (for format-specific, non-reusable)
class BlogPost < Lutaml::Model::Serializable
attribute :published_on, :date
attribute :title, :string
# XML API requires YYYYMMDD
xml do
root "post"
map_element "publishDate", to: :published_on, transform: {
export: ->(date) { date&.strftime("%Y%m%d") },
import: ->(str) {
return nil if str.nil? || str.empty?
Date.new(str[0..3].to_i, str[4..5].to_i, str[6..7].to_i)
}
}
map_element "title", to: :title
end
# JSON API requires DD-MM-YYYY
json do
map "publishDate", to: :published_on, transform: {
export: ->(date) { date&.strftime("%d-%m-%Y") },
import: ->(str) {
return nil if str.nil? || str.empty?
parts = str.split("-")
Date.new(parts[2].to_i, parts[1].to_i, parts[0].to_i)
}
}
map "title", to: :title
end
# Internal YAML uses ISO8601 (no transform)
yaml do
map "publishDate", to: :published_on
map "title", to: :title
end
endpost = BlogPost.new(
published_on: Date.new(2024, 12, 25),
title: "Holiday Special"
)
# Each format gets appropriate representation
post.to_xml
# => <post>
# <publishDate>20241225</publishDate>
# <title>Holiday Special</title>
# </post>
post.to_json
# => {"publishDate":"25-12-2024","title":"Holiday Special"}
post.to_yaml
# => ---
# => publishDate: '2024-12-25'
# => title: Holiday Special
# All deserialize correctly
BlogPost.from_xml(post.to_xml).published_on
# => #<Date: 2024-12-25>
BlogPost.from_json(post.to_json).published_on
# => #<Date: 2024-12-25>Best Practices
Design Principles
-
Value Transformation Principle
When a value needs to be transformed (not just mapped), use transformations. Mapping is for structural reorganization, transformation is for computational conversion.
-
Bidirectionality Principle
Always implement both
importandexport(orfrom_*andto_*) unless the transformation is genuinely one-way. -
Format Awareness Principle
Consider whether different serialization formats need different representations. If yes, use Custom Value Types or Mapping Transforms.
-
Reusability Principle
If the logic will be used in multiple places, encapsulate it in a Custom Value Type class.
-
Clarity Principle
Choose the approach that makes the transformation logic most clear and maintainable.
Common Patterns
Pattern: Date Format Adapter
When working with external systems that require specific date formats:
# Create a custom type for the external system's format
class LegacySystemDate < Lutaml::Model::Type::Date
# Legacy system uses MMDDYYYY
def self.from_xml(value)
return nil if value.nil? || value.empty?
month = value[0..1].to_i
day = value[2..3].to_i
year = value[4..7].to_i
::Date.new(year, month, day)
end
def to_xml
value&.strftime("%m%d%Y")
end
endPattern: Calculated Derived Values
When values are calculated from dates:
class FiscalWeek < Lutaml::Model::Type::Integer
# Assumes fiscal year starts April 1
def self.from_xml(date_string)
date = Date.parse(date_string)
fiscal_year_start = Date.new(
date.month >= 4 ? date.year : date.year - 1,
4,
1
)
((date - fiscal_year_start) / 7).to_i + 1
end
def to_xml
# Just output the week number
value.to_s
end
endPattern: Multi-Stage Transformation
Complex transformations can be broken into stages:
class ComplexDate < Lutaml::Model::Type::Date
def self.from_xml(value)
# Stage 1: Parse format
parsed = parse_custom_format(value)
# Stage 2: Validate
validate_date_range!(parsed)
# Stage 3: Adjust (e.g., timezone)
adjust_timezone(parsed)
end
private
def self.parse_custom_format(value)
# Parsing logic
end
def self.validate_date_range!(date)
# Validation logic
end
def self.adjust_timezone(date)
# Timezone logic
end
endPerformance Considerations
-
Custom Value Types are more performant for reused logic (compiled methods vs repeated proc calls)
-
Transform Procs are fine for simple, infrequent transformations
-
Avoid excessive chaining of transforms; consolidate logic when possible
-
Cache expensive calculations within custom type methods if needed
Migration Guide
From Transform Procs to Custom Value Types
If you find yourself repeating the same transform logic across multiple attributes:
class Model1 < Lutaml::Model::Serializable
attribute :date1, :date, transform: {
export: ->(d) { d&.strftime("%Y%m%d") },
import: ->(s) { Date.new(s[0..3].to_i, s[4..5].to_i, s[6..7].to_i) }
}
end
class Model2 < Lutaml::Model::Serializable
attribute :date2, :date, transform: {
export: ->(d) { d&.strftime("%Y%m%d") },
import: ->(s) { Date.new(s[0..3].to_i, s[4..5].to_i, s[6..7].to_i) }
}
endclass YYYYMMDDDate < Lutaml::Model::Type::Date
def self.cast(value)
case value
when ::Date then value
when ::String then from_xml(value) # Reuse parsing logic
else super
end
end
def self.from_xml(value)
return nil if value.nil? || value.empty?
Date.new(value[0..3].to_i, value[4..5].to_i, value[6..7].to_i)
end
def to_xml
value&.strftime("%Y%m%d")
end
# Apply to all formats
alias_method :to_json, :to_xml
alias_method :to_yaml, :to_xml
class << self
alias_method :from_json, :from_xml
alias_method :from_yaml, :from_xml
end
end
class Model1 < Lutaml::Model::Serializable
attribute :date1, YYYYMMDDDate
end
class Model2 < Lutaml::Model::Serializable
attribute :date2, YYYYMMDDDate
endSummary
Quick Reference
Choose your transformation approach:
-
Custom Value Type when:
-
Reusable logic
-
Format-specific needs
-
Bidirectional transformations
-
Complex parsing/calculation
-
-
Attribute Transform when:
-
Uniform across all formats
-
Simple logic
-
Single attribute use
-
Quick inline transformation
-
-
Mapping Transform when:
-
Format-specific requirements
-
One-off transformation
-
Combined with other approaches
-
API-specific formatting
-
Key Takeaways
-
Values cannot be mapped, they must be transformed
-
Models can be mapped, they don’t need transformation
-
Format conversion is bidirectional, calculated values may be unidirectional
-
Precedence matters when combining attribute and mapping transforms
-
Reusability drives the choice between Custom Types and Transform Procs