Workflow & Activity Separation
Lesson 6: Clean Architecture for Temporal Workflows
Learn how to design and implement clean, maintainable Temporal workflows by properly separating concerns across multiple activities.
Objective
By the end of this lesson, you will understand:
- ✅ Single Responsibility Principle (SRP) applied to Temporal workflows
- ✅ Activity timeout strategies for different operation types
- ✅ Error handling patterns for critical vs non-critical failures
- ✅ Data modeling best practices with typed result objects
- ✅ Workflow orchestration patterns for complex business processes
- ✅ Testing strategies for maintainable code
1. Single Responsibility Principle (SRP) in Temporal
Why SRP Matters in Workflows
Each activity should have one reason to change. This makes your system:
- ✅ Easier to test - focused components are simpler to unit test
- ✅ More maintainable - changes to one concern don't affect others
- ✅ More scalable - different activities can have different scaling requirements
- ✅ More resilient - failures in one area don't necessarily affect others
Good vs Bad Activity Design
❌ BAD: One activity doing too much
@ActivityInterface
interface UserProcessingActivity {
@ActivityMethod
fun processUser(email: String): String // Validates, creates account, sends email
}
Problem: Multiple responsibilities in one activity
Good Activity Design
✅ GOOD: Separate concerns
@ActivityInterface
interface UserValidationActivity {
@ActivityMethod
fun validateUser(email: String): ValidationResult
}
@ActivityInterface
interface AccountCreationActivity {
@ActivityMethod
fun createAccount(email: String): CreationResult
}
@ActivityInterface
interface NotificationActivity {
@ActivityMethod
fun sendWelcomeEmail(email: String, userId: String): NotificationResult
}
Result: Clear, focused responsibilities for each activity
2. Activity Timeout Strategy
Different Operations Need Different Timeouts
class UserOnboardingWorkflowImpl : UserOnboardingWorkflow {
// Quick validation - short timeout
private val validationActivity = Workflow.newActivityStub(
UserValidationActivity::class.java,
ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(10))
.build()
)
// More activities on next slide...
More Timeout Examples
// Database operations - medium timeout
private val accountCreationActivity = Workflow.newActivityStub(
AccountCreationActivity::class.java,
ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(30))
.build()
)
// External services - longer timeout
private val notificationActivity = Workflow.newActivityStub(
NotificationActivity::class.java,
ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofMinutes(2))
.build()
)
}
Timeout Strategy Guidelines
Operation-Based Timeout Recommendations:
- In-memory operations: 5-10 seconds
- Database queries: 15-30 seconds
- Database transactions: 30-60 seconds
- External API calls: 1-5 minutes
- File processing: 5-30 minutes
- Long computations: 30+ minutes
Choose timeouts based on the actual operation characteristics!
3. Error Handling Patterns
Critical vs Non-Critical Failures
override fun onboardUser(email: String): OnboardingResult {
// Critical failure - stops the entire process
val validation = validationActivity.validateUser(email)
if (!validation.isValid) {
return OnboardingResult(false, null, validation.errorMessage!!, steps)
}
// Critical failure - user can't be created without this
val creation = accountCreationActivity.createAccount(email)
if (!creation.success) {
return OnboardingResult(false, null, creation.errorMessage!!, steps)
}
// Continued on next slide...
Non-Critical Error Handling
// Non-critical failure - best effort, don't fail the whole process
try {
val notification = notificationActivity.sendWelcomeEmail(email, userId)
// Log but continue even if notification fails
} catch (e: Exception) {
logger.warn("Notification failed but user was created successfully: ${e.message}")
}
return OnboardingResult(true, userId, "Success", steps)
}
Key Principle: Fail fast for critical operations, gracefully degrade for non-critical ones
Failure Strategy Decision Matrix
Operation Type | Failure Impact | Strategy |
---|---|---|
Data Validation | High | Fail fast, stop process |
Account Creation | High | Fail and rollback |
Payment Processing | High | Fail and alert |
Welcome Email | Low | Log and continue |
Analytics Event | Low | Retry later, don't block |
Audit Logging | Medium | Retry with backoff |
4. Data Modeling Best Practices
Typed Result Objects
// Clear, specific result types
data class ValidationResult(
val isValid: Boolean,
val errorMessage: String?
)
data class CreationResult(
val success: Boolean,
val userId: String?,
val errorMessage: String?
)
data class NotificationResult(
val sent: Boolean,
val errorMessage: String?
)
Comprehensive Result Objects
// Comprehensive workflow result
data class OnboardingResult(
val success: Boolean,
val userId: String?,
val message: String,
val steps: List<String> // Audit trail
)
Why Typed Results Matter
- ✅ Type Safety: Compile-time checking prevents errors
- ✅ Clear Contracts: Each activity's responsibility is explicit
- ✅ Evolution: Easy to add fields without breaking existing code
- ✅ Testing: Clear expectations for unit tests
5. Workflow Orchestration Patterns
Sequential Processing
// Steps must happen in order
val validation = validationActivity.validateUser(email)
if (!validation.isValid) return failure(validation.errorMessage)
val creation = accountCreationActivity.createAccount(email)
if (!creation.success) return failure(creation.errorMessage)
val notification = notificationActivity.sendWelcomeEmail(email, userId)
When to use: When each step depends on the previous one
Parallel Processing
Parallel Processing (for future lessons)
// Independent operations can run in parallel
val validationFuture = Async.function { validationActivity.validateUser(email) }
val configFuture = Async.function { configActivity.setupDefaults(email) }
val validation = validationFuture.get()
val config = configFuture.get()
When to use: When operations are independent and can run concurrently
Conditional Processing
Conditional Processing
// Different paths based on business rules
val userType = classificationActivity.classifyUser(email)
when (userType) {
UserType.PREMIUM -> {
premiumOnboardingActivity.setupPremiumFeatures(userId)
}
UserType.STANDARD -> {
standardOnboardingActivity.setupBasicFeatures(userId)
}
UserType.TRIAL -> {
trialOnboardingActivity.setupTrialFeatures(userId)
}
}
When to use: When business logic requires different paths
6. Testing Strategy
Unit Testing Activities
@Test
fun `should validate correct email format`() {
val activity = UserValidationActivityImpl()
val result = activity.validateUser("test@example.com")
assertThat(result.isValid).isTrue()
assertThat(result.errorMessage).isNull()
}
@Test
fun `should reject invalid email format`() {
val activity = UserValidationActivityImpl()
val result = activity.validateUser("invalid-email")
assertThat(result.isValid).isFalse()
assertThat(result.errorMessage).isEqualTo("Invalid email format")
}
Integration Testing Workflows
@Test
fun `should complete full user onboarding`() {
val testEnv = TestWorkflowEnvironment.newInstance()
val worker = testEnv.newWorker("test-queue")
worker.registerWorkflowImplementationTypes(UserOnboardingWorkflowImpl::class.java)
worker.registerActivitiesImplementations(
UserValidationActivityImpl(),
AccountCreationActivityImpl(),
NotificationActivityImpl()
)
testEnv.start()
val workflow = testEnv.workflowClient.newWorkflowStub(
UserOnboardingWorkflow::class.java
)
val result = workflow.onboardUser("test@example.com")
assertThat(result.success).isTrue()
assertThat(result.userId).isNotNull()
assertThat(result.steps).hasSize(3)
}
Best Practices
✅ Activity Organization
1. Domain-Driven Design
// Group by business domain
com.company.user.validation.UserValidationActivity
com.company.user.account.AccountCreationActivity
com.company.notification.EmailNotificationActivity
com.company.billing.PaymentActivity
Organize by business domain, not technical concerns
More Organization Best Practices
2. Interface Segregation
// Specific interfaces, not generic ones
interface UserValidationActivity {
fun validateUser(email: String): ValidationResult
}
// Not this:
interface GenericActivity {
fun process(data: Any): Any
}
3. Dependency Injection
@Component
class UserValidationActivityImpl(
private val userRepository: UserRepository,
private val emailValidator: EmailValidator
) : UserValidationActivity {
// Use injected dependencies
}
✅ Error Handling Best Practices
1. Fail Fast for Critical Errors
if (!validation.isValid) {
// Stop immediately, don't waste resources
return OnboardingResult(false, null, validation.errorMessage!!, steps)
}
2. Graceful Degradation for Non-Critical
try {
notificationActivity.sendWelcomeEmail(email, userId)
} catch (e: Exception) {
// Log but don't fail the workflow
logger.warn("Email failed but user was created: ${e.message}")
}
More Error Handling
3. Detailed Error Context
data class ValidationResult(
val isValid: Boolean,
val errorMessage: String?,
val errorCode: String? = null,
val failedField: String? = null
)
Provide enough context for debugging and user feedback
❌ Common Anti-Patterns
1. God Activities
// Bad: One activity doing everything
interface UserManagementActivity {
fun processCompleteUserLifecycle(data: UserData): Result
}
2. Shared Mutable State
// Bad: Activities sharing state
class BadActivityImpl {
companion object {
var sharedCounter = 0 // Don't do this!
}
}
Final Anti-Pattern
3. No Error Differentiation
// Bad: All failures treated the same
if (validation.failed || creation.failed || notification.failed) {
return failure("Something went wrong")
}
Always differentiate between critical and non-critical failures
💡 Key Takeaways
What You've Learned:
- ✅ Single Responsibility Principle creates maintainable activities
- ✅ Timeout strategies should match operation characteristics
- ✅ Error handling requires differentiating critical vs non-critical failures
- ✅ Typed result objects provide clear contracts and type safety
- ✅ Orchestration patterns handle different business scenarios
- ✅ Testing strategies ensure reliable, maintainable code