Workflow Input/Output
Lesson 7: Advanced Data Modeling Patterns
Master advanced data modeling patterns for Temporal workflows, including complex input validation, rich output structures, and data transformation strategies.
Objective
By the end of this lesson, you will understand:
- ✅ Complex input data modeling with structured objects
- ✅ Input validation patterns for robust workflows
- ✅ Rich output data structures with comprehensive results
- ✅ Data transformation strategies between workflows and activities
- ✅ Serialization considerations for Temporal compatibility
- ✅ Error context in results for better debugging
1. Complex Input Data Modeling
Structured Input Objects
data class OrderRequest(
val customerId: String,
val items: List<OrderItem>,
val shippingAddress: Address,
val paymentMethod: PaymentMethod,
val metadata: Map<String, String> = emptyMap()
)
data class OrderItem(
val productId: String,
val quantity: Int,
val unitPrice: BigDecimal,
val customizations: List<ProductCustomization> = emptyList()
)
Build complex, structured data models for realistic business scenarios
Input Validation Patterns
class OrderProcessingWorkflowImpl : OrderProcessingWorkflow {
override fun processOrder(orderRequest: OrderRequest): OrderResult {
// Validate inputs before processing
validateOrderRequest(orderRequest)
// Process with validated data
return processValidatedOrder(orderRequest)
}
private fun validateOrderRequest(request: OrderRequest) {
require(request.customerId.isNotBlank()) { "Customer ID is required" }
require(request.items.isNotEmpty()) { "Order must contain at least one item" }
require(request.items.all { it.quantity > 0 }) { "All items must have positive quantity" }
require(request.items.all { it.unitPrice > BigDecimal.ZERO }) { "All items must have positive price" }
}
}
Why Validate Early?
Benefits of Early Validation:
- ✅ Fail fast - Don't waste time on invalid data
- ✅ Clear error messages - Specific validation feedback
- ✅ Resource efficiency - Don't consume workflow/activity resources
- ✅ Better debugging - Know exactly what went wrong
- ✅ User experience - Immediate feedback on problems
Always validate in workflows before calling activities
2. Rich Output Data Structures
Comprehensive Result Objects
data class OrderResult(
val orderId: String,
val status: OrderStatus,
val totalAmount: BigDecimal,
val estimatedDelivery: LocalDate?,
val trackingInfo: TrackingInfo?,
val processingSteps: List<ProcessingStep>,
val metadata: OrderMetadata
)
data class ProcessingStep(
val stepName: String,
val status: StepStatus,
val executedAt: Instant,
val duration: Duration,
val details: Map<String, Any> = emptyMap()
)
More Result Structures
data class OrderMetadata(
val processingTime: Duration,
val version: String,
val systemInfo: SystemInfo
)
Why Rich Results Matter:
- ✅ Audit trail - Track what happened and when
- ✅ Debugging - Understand processing flow
- ✅ Monitoring - Performance metrics and timing
- ✅ Business intelligence - Rich data for analysis
- ✅ User feedback - Detailed status information
3. Data Transformation Strategies
Workflow-to-Activity Data Flow
class OrderProcessingWorkflowImpl : OrderProcessingWorkflow {
override fun processOrder(orderRequest: OrderRequest): OrderResult {
// Transform request data for different activities
val validationInput = ValidationInput.from(orderRequest)
val validationResult = validationActivity.validateOrder(validationInput)
val pricingInput = PricingInput.from(orderRequest, validationResult)
val pricingResult = pricingActivity.calculatePricing(pricingInput)
val paymentInput = PaymentInput.from(orderRequest, pricingResult)
val paymentResult = paymentActivity.processPayment(paymentInput)
// Aggregate results into comprehensive output
return OrderResult.builder()
.withOrderRequest(orderRequest)
.withValidation(validationResult)
.withPricing(pricingResult)
.withPayment(paymentResult)
.build()
}
}
Activity-Specific Data Models
// Validation activity input/output
data class ValidationInput(
val customerId: String,
val items: List<ItemToValidate>,
val shippingAddress: Address
) {
companion object {
fun from(orderRequest: OrderRequest): ValidationInput {
return ValidationInput(
customerId = orderRequest.customerId,
items = orderRequest.items.map { ItemToValidate.from(it) },
shippingAddress = orderRequest.shippingAddress
)
}
}
}
More Data Transformation
// Pricing activity input/output
data class PricingInput(
val items: List<PricingItem>,
val customerId: String,
val promotionCodes: List<String>
) {
companion object {
fun from(orderRequest: OrderRequest, validationResult: ValidationResult): PricingInput {
return PricingInput(
items = orderRequest.items.map { PricingItem.from(it) },
customerId = orderRequest.customerId,
promotionCodes = validationResult.applicablePromotions
)
}
}
}
Each activity gets exactly the data it needs in the format it expects
4. Serialization Considerations
Temporal-Safe Data Types
// Good: Temporal-serializable types
data class OrderRequest(
val customerId: String, // ✅ String
val orderDate: LocalDateTime, // ✅ LocalDateTime
val totalAmount: BigDecimal, // ✅ BigDecimal
val items: List<OrderItem>, // ✅ List of data classes
val metadata: Map<String, String> // ✅ Map with serializable types
)
Stick to standard, serializable types for reliable data transfer
Avoid Non-Serializable Types
// Avoid: Non-serializable types
data class BadOrderRequest(
val customerId: String,
val callback: () -> Unit, // ❌ Function
val inputStream: InputStream, // ❌ Stream
val complexObject: SomeComplexClass // ❌ Non-serializable class
)
Safe Data Types for Temporal:
- ✅ Primitives: String, Int, Long, Double, Boolean
- ✅ Collections: List, Set, Map (with serializable contents)
- ✅ Time: LocalDate, LocalDateTime, Instant, Duration
- ✅ Numbers: BigDecimal, BigInteger
- ✅ Data classes: With serializable properties
Version-Safe Evolution
data class OrderRequest(
val customerId: String,
val items: List<OrderItem>,
val shippingAddress: Address,
// Safe to add optional fields
val priority: OrderPriority = OrderPriority.NORMAL,
val giftMessage: String? = null,
val requestedDeliveryDate: LocalDate? = null
) {
// Version handling
companion object {
const val CURRENT_VERSION = "2.1"
}
}
Add new fields as optional with defaults to maintain backward compatibility
5. Error Context in Results
Rich Error Information
sealed class OrderResult {
abstract val orderId: String
abstract val processingSteps: List<ProcessingStep>
data class Success(
override val orderId: String,
val totalAmount: BigDecimal,
val estimatedDelivery: LocalDate,
override val processingSteps: List<ProcessingStep>
) : OrderResult()
data class Failure(
override val orderId: String,
val errorType: ErrorType,
val errorMessage: String,
val failedStep: String,
val recoveryActions: List<RecoveryAction>,
override val processingSteps: List<ProcessingStep>
) : OrderResult()
More Result Types
data class PartialSuccess(
override val orderId: String,
val completedSteps: List<String>,
val failedSteps: List<FailedStep>,
val canRetry: Boolean,
override val processingSteps: List<ProcessingStep>
) : OrderResult()
}
Use sealed classes to represent different outcome scenarios clearly
Best Practices
✅ Input Design
1. Use Data Classes
// Good: Immutable data classes
data class CreateUserRequest(
val email: String,
val name: String,
val preferences: UserPreferences
)
// Bad: Mutable classes
class CreateUserRequest {
var email: String? = null
var name: String? = null
}
Immutable data classes provide thread safety and clear contracts
More Input Best Practices
2. Validate Early
override fun processOrder(request: OrderRequest): OrderResult {
// Validate in workflow before calling activities
validateOrderRequest(request)
// Now safe to process
return processValidatedOrder(request)
}
3. Use Builders for Complex Objects
class OrderRequestBuilder {
private var customerId: String? = null
private var items: MutableList<OrderItem> = mutableListOf()
fun customerId(id: String) = apply { this.customerId = id }
fun addItem(item: OrderItem) = apply { this.items.add(item) }
fun build(): OrderRequest {
requireNotNull(customerId) { "Customer ID is required" }
require(items.isNotEmpty()) { "At least one item required" }
return OrderRequest(customerId = customerId!!, items = items.toList())
}
}
✅ Output Design
1. Include Processing Context
data class ProcessingResult(
val success: Boolean,
val data: ResultData?,
val error: ErrorInfo?,
val processingTime: Duration,
val stepResults: List<StepResult>
)
2. Use Sealed Classes for Status
sealed class OrderStatus {
object Processing : OrderStatus()
object Confirmed : OrderStatus()
data class Shipped(val trackingNumber: String) : OrderStatus()
data class Failed(val reason: String) : OrderStatus()
}
More Output Best Practices
3. Provide Audit Trail
data class OrderResult(
val orderId: String,
val finalStatus: OrderStatus,
val auditTrail: List<AuditEvent>
)
data class AuditEvent(
val timestamp: Instant,
val action: String,
val actor: String,
val details: Map<String, Any>
)
Rich audit trails enable debugging and compliance
❌ Common Anti-Patterns
1. Overly Complex Input Objects
// Bad: Too many nested levels
data class OverlyComplexRequest(
val level1: Level1Data,
val level2: Map<String, Level2Data>,
val level3: List<Map<String, List<Level3Data>>>
)
2. Stringly Typed Data
// Bad: Everything as strings
data class BadRequest(
val amount: String, // Should be BigDecimal
val date: String, // Should be LocalDate
val status: String // Should be enum
)
Final Anti-Pattern
3. Missing Error Context
// Bad: Minimal error info
data class BadResult(
val success: Boolean,
val error: String?
)
// Good: Rich error context
data class GoodResult(
val success: Boolean,
val data: ResultData?,
val error: DetailedError?
)
Always provide enough context for debugging and user feedback
💡 Key Takeaways
What You've Learned:
- ✅ Structured input objects with proper validation
- ✅ Rich output structures with audit trails and metadata
- ✅ Data transformation patterns for activity-specific models
- ✅ Serialization best practices for Temporal compatibility
- ✅ Error-rich results with detailed context
- ✅ Version-safe evolution for long-term maintainability