In our previous lesson, you learned how to eliminate duplicated code through method extraction and the refactoring of magic numbers. This lesson builds upon that foundation by applying it to another code smell. This technique is vital for transforming long, complex methods into smaller, more manageable ones, enhancing both readability and maintainability. As we delve into this lesson, remember that our goal is to follow the Test Driven Development (TDD) workflow: Red, Green, Refactor. This iterative cycle ensures that we can leverage our tests when refactoring to confirm that we have not changed anything about the behavior. If you change behavior, it is not a successful refactor.
Long methods are a code smell that can hinder efficient development, as they often become difficult to understand, test, and maintain. A method might be considered long if it handles multiple responsibilities, making the code harder to track and debug. This complexity can impede our ability to effectively employ the TDD cycle, as isolated testing of functionalities becomes more challenging. Our task is to identify such cumbersome methods and employ the Extract Method technique to break them down into smaller, focused sub-methods, each with a single responsibility.
Take a look at the following method. Notice how it is not only long, but it is also responsible for doing a lot of things. Can you identify the different things this method is responsible for defining?
Kotlin1import java.time.LocalDate 2import java.time.Period 3import java.util.UUID 4 5data class UserData( 6 val username: String?, 7 val email: String?, 8 val password: String?, 9 val dateOfBirth: String, 10 val address: Address 11) 12 13data class Address( 14 val street: String?, 15 val city: String?, 16 val country: String?, 17 val postalCode: String? 18) 19 20data class RegistrationResponse( 21 val success: Boolean, 22 val message: String, 23 val userId: String? 24) 25 26interface IDataStore { 27 fun store(userData: UserData) 28} 29 30class UserRegistrationService(private val dataStore: IDataStore) { 31 32 fun processUserRegistration(userData: UserData): RegistrationResponse { 33 return try { 34 // User validation 35 if (userData.username.isNullOrBlank() || userData.username.length < 3 || userData.username.length > 20) { 36 return RegistrationResponse(false, "Invalid username. Must be between 3 and 20 characters.", null) 37 } 38 39 if (userData.email.isNullOrBlank() || !userData.email.contains("@") || !userData.email.contains(".")) { 40 return RegistrationResponse(false, "Invalid email format.", null) 41 } 42 43 if (userData.password.isNullOrBlank() 44 || userData.password.length < 8 45 || !userData.password.contains(Regex(".*[A-Z].*")) 46 || !userData.password.contains(Regex(".*\\d.*")) 47 || !userData.password.contains(Regex(".*[!@#\$%^&*].*")) 48 ) { 49 return RegistrationResponse(false, "Password must be at least 8 characters and contain uppercase, number, and special character.", null) 50 } 51 52 // Date validation 53 val birthDate = LocalDate.parse(userData.dateOfBirth) 54 val today = LocalDate.now() 55 val age = Period.between(birthDate, today) 56 if (age.years < 18 || age.years > 120) { 57 return RegistrationResponse(false, "Invalid date of birth or user must be 18+", null) 58 } 59 60 // Address validation 61 val address = userData.address 62 if (address.street.isNullOrBlank() || address.street.length < 5) { 63 return RegistrationResponse(false, "Invalid street address", null) 64 } 65 if (address.city.isNullOrBlank() || address.city.length < 2) { 66 return RegistrationResponse(false, "Invalid city", null) 67 } 68 if (address.country.isNullOrBlank() || address.country.length < 2) { 69 return RegistrationResponse(false, "Invalid country", null) 70 } 71 if (address.postalCode.isNullOrBlank() || !address.postalCode.matches(Regex("^[A-Z0-9]{3,10}$"))) { 72 return RegistrationResponse(false, "Invalid postal code", null) 73 } 74 75 // Data transformation 76 val userId = "USER_" + UUID.randomUUID().toString().substring(0, 8) 77 val normalizedData = UserData( 78 userData.username.toLowerCase(), 79 userData.email.toLowerCase(), 80 userData.password, 81 birthDate.toString(), 82 Address( 83 address.street.trim(), 84 address.city.trim(), 85 address.country.toUpperCase(), 86 address.postalCode.toUpperCase() 87 ) 88 ) 89 90 dataStore.store(normalizedData) 91 92 RegistrationResponse(true, "User registered successfully", userId) 93 } catch (e: Exception) { 94 RegistrationResponse(false, "Registration failed: ${e.message}", null) 95 } 96 } 97}
The processUserRegistration
method performs multiple tasks:
- User Validation: Checks that the username, email, and password meet specific criteria.
- Date Validation: Verifies that the user’s date of birth is valid and within a specific age range.
- Address Validation: Ensures that each part of the address (e.g., street, city, country, postal code) follows certain rules.
- Data Transformation: Normalizes data (e.g., converting email and username to lowercase).
- Data Storage: Saves the user data to the datastore.
- Error Handling: Catches and returns any errors encountered.
By isolating each of these responsibilities into separate methods, we can improve readability and reusability and make our code easier to maintain.
We can extract the user validation functionality into its own method:
Kotlin1private fun validateUser(userData: UserData): RegistrationResponse? { 2 if (userData.username.isNullOrBlank() || userData.username.length < 3 || userData.username.length > 20) { 3 return RegistrationResponse(false, "Invalid username. Must be between 3 and 20 characters.", null) 4 } 5 6 if (userData.email.isNullOrBlank() || !userData.email.contains("@") || !userData.email.contains(".")) { 7 return RegistrationResponse(false, "Invalid email format.", null) 8 } 9 10 if (userData.password.isNullOrBlank() 11 || userData.password.length < 8 12 || !userData.password.contains(Regex(".*[A-Z].*")) 13 || !userData.password.contains(Regex(".*\\d.*")) 14 || !userData.password.contains(Regex(".*[!@#\$%^&*].*")) 15 ) { 16 return RegistrationResponse(false, "Password must be at least 8 characters and contain uppercase, number, and special character.", null) 17 } 18 return null 19}
We can then modify the processUserRegistration
method to utilize this new method:
Kotlin1fun processUserRegistration(userData: UserData): RegistrationResponse { 2 return try { 3 // User validation 4 val validationResponse = validateUser(userData) 5 if (validationResponse != null) { 6 return validationResponse 7 } 8 9 // Date validation 10 val birthDate = LocalDate.parse(userData.dateOfBirth) 11 val today = LocalDate.now() 12 val age = Period.between(birthDate, today) 13 if (age.years < 18 || age.years > 120) { 14 return RegistrationResponse(false, "Invalid date of birth or user must be 18+", null) 15 } 16 17 // Address validation 18 val address = userData.address 19 if (address.street.isNullOrBlank() || address.street.length < 5) { 20 return RegistrationResponse(false, "Invalid street address", null) 21 } 22 if (address.city.isNullOrBlank() || address.city.length < 2) { 23 return RegistrationResponse(false, "Invalid city", null) 24 } 25 if (address.country.isNullOrBlank() || address.country.length < 2) { 26 return RegistrationResponse(false, "Invalid country", null) 27 } 28 if (address.postalCode.isNullOrBlank() || !address.postalCode.matches(Regex("^[A-Z0-9]{3,10}$"))) { 29 return RegistrationResponse(false, "Invalid postal code", null) 30 } 31 32 // Data transformation 33 val userId = "USER_" + UUID.randomUUID().toString().substring(0, 8) 34 val normalizedData = UserData( 35 userData.username.toLowerCase(), 36 userData.email.toLowerCase(), 37 userData.password, 38 birthDate.toString(), 39 Address( 40 address.street.trim(), 41 address.city.trim(), 42 address.country.toUpperCase(), 43 address.postalCode.toUpperCase() 44 ) 45 ) 46 47 dataStore.store(normalizedData) 48 49 RegistrationResponse(true, "User registered successfully", userId) 50 } catch (e: Exception) { 51 RegistrationResponse(false, "Registration failed: ${e.message}", null) 52 } 53}
This version already looks much better! Can you already see other parts of the method that could be extracted into separate methods?
Applying the Extract Method makes our code more readable and allows for individual components to be reusable across our codebase. Testing specific functionalities separately enhances our debugging capabilities and reduces complexity. This approach aligns with the TDD principles of making small, incremental changes followed by comprehensive testing.
In this lesson, we have built on our refactoring skills by employing the Extract Method pattern to manage long methods within a class. Key takeaways include:
- Identifying long methods and articulating their issues.
- Reinforcing the benefits of maintainable and readable code through method extraction.
As you proceed, the upcoming practice exercises will give you hands-on experience in implementing these techniques. Continue applying TDD principles in your work for more efficient, robust, and scalable applications. Keep up the excellent work! You're well on your way to mastering clean and sustainable code practices.
