Introduction & Context Setting

In our previous lesson, you learned how to eliminate duplicated code in Go by extracting functions and refactoring magic numbers. This lesson builds on that foundation by identifying another prevalent code smell — long functions. Transforming lengthy, complex functions into smaller, more manageable ones enhances readability and maintainability. Throughout this lesson, we'll adhere to the Test Driven Development (TDD) workflow: Red, Green, Refactor. Using this iterative cycle ensures that our refactoring maintains the existing behavior, with tests verifying that any adjustments have not altered functionality.

Understanding the Problem with Long Functions

Long functions can be a significant obstacle in software development, making code difficult to understand, test, and maintain. When a function tackles multiple responsibilities, it becomes harder to track and debug, complicating the TDD cycle's effectiveness. Our task is to identify these lengthy functions and use the Extract Function technique to break them down into smaller, focused functions, each with a single purpose.

An Example of a Long Function

Consider the following Go function. It is not only lengthy but also handles various tasks. Can you identify what responsibilities this function includes?

func (urs *UserRegistrationService) ProcessUserRegistration(userData UserData) RegistrationResponse {
    // TODO: Extract User Validation logic, not including the date logic
    // User validation
    if len(userData.Username) < 3 || len(userData.Username) > 20 {
        return RegistrationResponse{false, "Invalid username. Must be between 3 and 20 characters.", ""}
    }
    
    if !strings.Contains(userData.Email, "@") || !strings.Contains(userData.Email, ".") {
        return RegistrationResponse{false, "Invalid email format.", ""}
    }
    
    hasUpper, _ := regexp.MatchString(`[A-Z]`, userData.Password)
    hasNumber, _ := regexp.MatchString(`[0-9]`, userData.Password)
    hasSpecial, _ := regexp.MatchString(`[!@#$%^&*]`, userData.Password)

    if len(userData.Password) < 8 || !hasUpper || !hasNumber || !hasSpecial {
        return RegistrationResponse{false, "Password must be at least 8 characters and contain uppercase, number, and special character.", ""}
    }
    
    // Date validation
    birthDate, err := time.Parse("2006-01-02", userData.DateOfBirth)
    if err != nil {
        return RegistrationResponse{false, err.Error(), ""}
    }

    if birthDate.AddDate(18, 0, 0).After(time.Now()) || birthDate.AddDate(120, 0, 0).Before(time.Now()) {
        return RegistrationResponse{false, "Invalid date of birth or user must be 18+", ""}
    }
    
    // Address validation
    address := userData.Address
    // Street validation
    if strings.TrimSpace(address.Street) == "" || len(address.Street) < 5 {
        return RegistrationResponse{false, "Invalid street address", ""}
    }

    // City validation
    if strings.TrimSpace(address.City) == "" || len(address.City) < 2 {
        return RegistrationResponse{false, "Invalid city", ""}
    }

    // Country validation
    if strings.TrimSpace(address.Country) == "" || len(address.Country) < 2 {
        return RegistrationResponse{false, "Invalid country", ""}
    }

    // Postal code validation
    var postalCodeRegex = regexp.MustCompile(`^[A-Z0-9]{3,10}$`)
    if strings.TrimSpace(address.PostalCode) == "" || 
       !postalCodeRegex.MatchString(address.PostalCode) {
        return RegistrationResponse{false, "Invalid postal code", ""}
    } 
    
    // Transform and store data
    userId := fmt.Sprintf("USER_%x", time.Now().UnixNano())
    normalizedData := UserData{
        Username:    strings.ToLower(userData.Username),
        Email:       strings.ToLower(userData.Email),
        Password:    userData.Password,
        DateOfBirth: birthDate.Format("2006-01-02"),
        Address: Address{
            Street:    strings.TrimSpace(userData.Address.Street),
            City:      strings.TrimSpace(userData.Address.City),
            Country:   strings.ToUpper(strings.TrimSpace(userData.Address.Country)),
            PostalCode: strings.ToUpper(strings.TrimSpace(userData.Address.PostalCode)),
        },
    }
    urs.dataStore.Store(normalizedData)
    return RegistrationResponse{true, "User registered successfully", userId}
}

The ProcessUserRegistration function encompasses multiple responsibilities:

  • User Validation: Checks that the username, email, and password meet specific criteria.
  • Date Validation: Ensures that the date of birth is valid and the user is of a certain age.
  • Address Validation: Ensures that address fields adhere to specified rules.
  • Data Transformation: Normalizes the user data.
  • Data Storage: Saves the data using the datastore.
  • Error Handling: Manages unexpected errors.

By isolating each responsibility into separate functions, we can significantly enhance code readability and reusability.

Refactor: Extract Function

Let's look at an example of extracting just one of the responsibilities—user validation—into its own function. This demonstrates the Extract Function technique in practice. Similarly, you can extract other responsibilities identified above (such as date validation, address validation, data transformation, and data storage) into their own dedicated functions to further improve clarity and maintainability.

func (urs *UserRegistrationService) validateUser(userData UserData) *RegistrationResponse {
    if len(userData.Username) < 3 || len(userData.Username) > 20 {
        return &RegistrationResponse{false, "Invalid username. Must be between 3 and 20 characters.", ""}
    }
    
    if !strings.Contains(userData.Email, "@") || !strings.Contains(userData.Email, ".") {
        return &RegistrationResponse{false, "Invalid email format.", ""}
    }
    
    hasUpper, _ := regexp.MatchString(`[A-Z]`, userData.Password)
    hasNumber, _ := regexp.MatchString(`[0-9]`, userData.Password)
    hasSpecial, _ := regexp.MatchString(`[!@#$%^&*]`, userData.Password)

    if len(userData.Password) < 8 || !hasUpper || !hasNumber || !hasSpecial {
        return &RegistrationResponse{false, "Password must be at least 8 characters and contain uppercase, number, and special character.", ""}
    }
    
    return nil
}
Benefits of the Extract Function

Using the Extract Function method makes our code more readable and allows individual components to be reusable throughout our codebase. Testing specific functionalities separately enhances our debugging capabilities and reduces complexity. This approach aligns with TDD principles, enabling us to make small, incremental changes followed by comprehensive tests.

Review, Summary, and Next Steps

In this lesson, we honed our refactoring skills by employing the Extract Function pattern to manage long functions within a Go application. Key takeaways include:

  • Identifying long functions and understanding their issues.
  • Enhancing code maintainability and readability through method extraction.

As you move forward, upcoming practice exercises will provide hands-on experience with these techniques, emphasizing TDD principles using Go and Testify to create more efficient, robust, and scalable applications. Keep practicing these skills to master clean and sustainable coding practices.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal