Refactoring a Simple C# Method Step by Step

Leader posted 8 min read

Refactoring a Simple C# Method Step by Step

Refactoring is not about changing what the code does. It is about improving how the code is written.

The goal is to make the code easier to read, easier to test, and easier to maintain.

In this tutorial, we will refactor a simple user registration method step by step.

The Original Code

Imagine we have a method like this:

public void RegisterUser(string username, string password, int age, string email)
{
    if (username == null || username == "")
    {
        throw new Exception("Username is required");
    }

    if (password == null || password == "")
    {
        throw new Exception("Password is required");
    }

    if (password.Length < 8)
    {
        throw new Exception("Password must be at least 8 characters");
    }

    if (age < 18 || age > 120)
    {
        throw new Exception("Invalid age");
    }

    if (email == null || !email.Contains("@") || !email.Contains("."))
    {
        throw new Exception("Invalid email");
    }

    Console.WriteLine("User registered successfully!");
}

The code works, but it has some problems.

What Is Wrong With This Code?

The method has too many responsibilities.

It validates the username, password, age, and email. It also performs the registration logic.

This makes the method harder to read and harder to test.

Some other issues are:

Validation logic is mixed with business logic.
The method is becoming too long.
The same type of checks are repeated.
The validation rules cannot easily be reused.
The method uses primitive values everywhere, like string and int.

This is a common problem called primitive obsession.

It means we are using simple types like string, int, or decimal for values that have business meaning.

For example, an email is not just a string. A password is not just a string. They both have rules.

Step 1: Use Guard Clauses

A guard clause is a validation check placed at the beginning of a method.

If the input is invalid, the method stops immediately.

This is also called Fail Fast.

public void RegisterUser(string username, string password, int age, string email)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(username);
    ArgumentException.ThrowIfNullOrWhiteSpace(password);

    if (password.Length < 8)
        throw new ArgumentException("Password must be at least 8 characters.", nameof(password));

    if (age is < 18 or > 120)
        throw new ArgumentOutOfRangeException(nameof(age), "Age must be between 18 and 120.");

    if (!IsValidEmail(email))
        throw new FormatException("Invalid email format.");

    Console.WriteLine("User registered successfully!");
}

private bool IsValidEmail(string email)
{
    return !string.IsNullOrWhiteSpace(email)
           && email.Contains("@")
           && email.Contains(".");
}

This version is already better.

We improved the code by using:

Guard Clauses
Extract Method
Fail Fast

The IsValidEmail method makes the code easier to read because the email validation is separated from the main method.

Step 2: Use a Request Object

The method currently receives four parameters.

public void RegisterUser(string username, string password, int age, string email)

This is fine for small examples, but in real applications, this can become messy.

A better approach is to group the input into a request object.

public class UserRegistrationRequest
{
    public string Username { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
    public int Age { get; set; }
    public string Email { get; set; } = string.Empty;
}

Now the method becomes cleaner:

public void RegisterUser(UserRegistrationRequest request)
{
    ArgumentNullException.ThrowIfNull(request);

    ArgumentException.ThrowIfNullOrWhiteSpace(request.Username);
    ArgumentException.ThrowIfNullOrWhiteSpace(request.Password);

    if (request.Password.Length < 8)
        throw new ArgumentException("Password must be at least 8 characters.", nameof(request.Password));

    if (request.Age is < 18 or > 120)
        throw new ArgumentOutOfRangeException(nameof(request.Age), "Age must be between 18 and 120.");

    if (!IsValidEmail(request.Email))
        throw new FormatException("Invalid email format.");

    Console.WriteLine("User registered successfully!");
}

This uses the DTO pattern, or Data Transfer Object.

A DTO is useful when we want to pass data between layers, for example from the UI to the application layer.

Step 3: Extract Validation Into a Validator

The registration method still contains validation logic.

We can move that logic into a separate class.

public class UserRegistrationValidator
{
    public void Validate(UserRegistrationRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        ArgumentException.ThrowIfNullOrWhiteSpace(request.Username);
        ArgumentException.ThrowIfNullOrWhiteSpace(request.Password);

        if (request.Password.Length < 8)
            throw new ArgumentException("Password must be at least 8 characters.", nameof(request.Password));

        if (request.Age is < 18 or > 120)
            throw new ArgumentOutOfRangeException(nameof(request.Age), "Age must be between 18 and 120.");

        if (!IsValidEmail(request.Email))
            throw new FormatException("Invalid email format.");
    }

    private static bool IsValidEmail(string email)
    {
        return !string.IsNullOrWhiteSpace(email)
               && email.Contains("@")
               && email.Contains(".");
    }
}

Now the registration method becomes very small:

public void RegisterUser(UserRegistrationRequest request)
{
    var validator = new UserRegistrationValidator();

    validator.Validate(request);

    Console.WriteLine("User registered successfully!");
}

This is much better.

The service now focuses on the registration process.

The validator focuses on validation.

This follows the Single Responsibility Principle.

Step 4: Inject the Validator

Creating the validator inside the method works, but it is not ideal for testing.

This line creates a direct dependency:

var validator = new UserRegistrationValidator();

A better approach is to inject the validator through the constructor.

First, create an interface:

public interface IUserRegistrationValidator
{
    void Validate(UserRegistrationRequest request);
}

Then update the validator:

public class UserRegistrationValidator : IUserRegistrationValidator
{
    public void Validate(UserRegistrationRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        ArgumentException.ThrowIfNullOrWhiteSpace(request.Username);
        ArgumentException.ThrowIfNullOrWhiteSpace(request.Password);

        if (request.Password.Length < 8)
            throw new ArgumentException("Password must be at least 8 characters.", nameof(request.Password));

        if (request.Age is < 18 or > 120)
            throw new ArgumentOutOfRangeException(nameof(request.Age), "Age must be between 18 and 120.");

        if (!IsValidEmail(request.Email))
            throw new FormatException("Invalid email format.");
    }

    private static bool IsValidEmail(string email)
    {
        return !string.IsNullOrWhiteSpace(email)
               && email.Contains("@")
               && email.Contains(".");
    }
}

Now the service can receive the validator from dependency injection:

public class UserRegistrationService
{
    private readonly IUserRegistrationValidator _validator;

    public UserRegistrationService(IUserRegistrationValidator validator)
    {
        _validator = validator;
    }

    public void RegisterUser(UserRegistrationRequest request)
    {
        _validator.Validate(request);

        Console.WriteLine("User registered successfully!");
    }
}

This improves testability because we can mock the validator in unit tests.

Step 5: Use Value Objects for Stronger Domain Rules

In a more domain-driven design approach, we can create value objects.

For example, instead of passing email as a plain string, we create an Email type.

public sealed record Email
{
    public string Value { get; }

    public Email(string value)
    {
        if (string.IsNullOrWhiteSpace(value) ||
            !value.Contains("@") ||
            !value.Contains("."))
        {
            throw new FormatException("Invalid email format.");
        }

        Value = value;
    }

    public override string ToString() => Value;
}

We can do the same for password.

public sealed record Password
{
    public string Value { get; }

    public Password(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new ArgumentException("Password is required.", nameof(value));

        if (value.Length < 8)
            throw new ArgumentException("Password must be at least 8 characters.", nameof(value));

        Value = value;
    }
}

Now the method can receive stronger types:

public void RegisterUser(string username, Password password, int age, Email email)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(username);

    if (age is < 18 or > 120)
        throw new ArgumentOutOfRangeException(nameof(age), "Age must be between 18 and 120.");

    Console.WriteLine("User registered successfully!");
}

This is powerful because invalid email or password values cannot exist in the system.

The validation happens when the object is created.

Step 6: Use the Specification Pattern for Complex Rules

Sometimes validation rules become more complex.

For example, age validation may start simple:

if (age is < 18 or > 120)
    throw new ArgumentOutOfRangeException(nameof(age));

But later, the rule may change depending on the country, user type, subscription, product, or business scenario.

In that case, we can move the rule into a separate specification class.

The Specification Pattern allows us to define business rules as reusable objects.

public interface ISpecification<T>
{
    bool IsSatisfiedBy(T entity);
}

Now we can create a specific rule for age validation:

public class AgeSpecification : ISpecification<int>
{
    public bool IsSatisfiedBy(int age)
    {
        return age is >= 18 and <= 120;
    }
}

Then we can use it inside our validator:

public class UserRegistrationValidator
{
    private readonly ISpecification<int> _ageSpecification;

    public UserRegistrationValidator(ISpecification<int> ageSpecification)
    {
        _ageSpecification = ageSpecification;
    }

    public void Validate(UserRegistrationRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        ArgumentException.ThrowIfNullOrWhiteSpace(request.Username);
        ArgumentException.ThrowIfNullOrWhiteSpace(request.Password);

        if (request.Password.Length < 8)
            throw new ArgumentException("Password must be at least 8 characters.", nameof(request.Password));

        if (!_ageSpecification.IsSatisfiedBy(request.Age))
            throw new ArgumentOutOfRangeException(nameof(request.Age), "Age must be between 18 and 120.");

        if (!IsValidEmail(request.Email))
            throw new FormatException("Invalid email format.");
    }

    private static bool IsValidEmail(string email)
    {
        return !string.IsNullOrWhiteSpace(email)
               && email.Contains("@")
               && email.Contains(".");
    }
}

This gives us a cleaner and more flexible design.

If the age rule changes later, we do not need to modify the validator directly. We can create a new specification.

For example:

public class AdultUserSpecification : ISpecification<UserRegistrationRequest>
{
    public bool IsSatisfiedBy(UserRegistrationRequest request)
    {
        return request.Age >= 18;
    }
}

Or we can create a more complete business rule:

public class ValidUserRegistrationSpecification : ISpecification<UserRegistrationRequest>
{
    public bool IsSatisfiedBy(UserRegistrationRequest request)
    {
        return !string.IsNullOrWhiteSpace(request.Username)
               && !string.IsNullOrWhiteSpace(request.Password)
               && request.Password.Length >= 8
               && request.Age is >= 18 and <= 120
               && !string.IsNullOrWhiteSpace(request.Email)
               && request.Email.Contains("@")
               && request.Email.Contains(".");
    }
}

The Specification Pattern is useful when:

Business rules are complex.
Rules change often.
Rules must be reused in different places.
Rules need to be combined.
You want to avoid large if statements inside services.

For simple validation, a validator is usually enough.

For complex domain rules, the Specification Pattern gives you more flexibility.

Patterns used here:

Specification Pattern
Open/Closed Principle
Single Responsibility Principle

Step 7: Use Result Instead of Exceptions

Exceptions are fine for unexpected errors.

But in APIs, UI forms, and validation-heavy workflows, returning a result can sometimes be cleaner.

public record Result(bool Success, string Error)
{
    public static Result Ok() => new(true, string.Empty);

    public static Result Fail(string error) => new(false, error);
}

Now the registration method can return a result:

public Result RegisterUser(UserRegistrationRequest request)
{
    if (string.IsNullOrWhiteSpace(request.Username))
        return Result.Fail("Username is required.");

    if (string.IsNullOrWhiteSpace(request.Password))
        return Result.Fail("Password is required.");

    if (request.Password.Length < 8)
        return Result.Fail("Password must be at least 8 characters.");

    if (request.Age is < 18 or > 120)
        return Result.Fail("Age must be between 18 and 120.");

    if (!IsValidEmail(request.Email))
        return Result.Fail("Invalid email format.");

    return Result.Ok();
}

private static bool IsValidEmail(string email)
{
    return !string.IsNullOrWhiteSpace(email)
           && email.Contains("@")
           && email.Contains(".");
}

This is useful when you do not want validation failures to throw exceptions.

It is especially useful in:

APIs
Blazor forms
MVC forms
CQRS command handlers
UI validation flows

For a real application, a clean structure would look like this:

public class UserRegistrationService
{
    private readonly IUserRegistrationValidator _validator;

    public UserRegistrationService(IUserRegistrationValidator validator)
    {
        _validator = validator;
    }

    public void RegisterUser(UserRegistrationRequest request)
    {
        _validator.Validate(request);

        Console.WriteLine("User registered successfully!");
    }
}
public interface IUserRegistrationValidator
{
    void Validate(UserRegistrationRequest request);
}
public class UserRegistrationValidator : IUserRegistrationValidator
{
    private readonly ISpecification<int> _ageSpecification;

    public UserRegistrationValidator(ISpecification<int> ageSpecification)
    {
        _ageSpecification = ageSpecification;
    }

    public void Validate(UserRegistrationRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        ArgumentException.ThrowIfNullOrWhiteSpace(request.Username);
        ArgumentException.ThrowIfNullOrWhiteSpace(request.Password);

        if (request.Password.Length < 8)
            throw new ArgumentException("Password must be at least 8 characters.", nameof(request.Password));

        if (!_ageSpecification.IsSatisfiedBy(request.Age))
            throw new ArgumentOutOfRangeException(nameof(request.Age), "Age must be between 18 and 120.");

        if (!IsValidEmail(request.Email))
            throw new FormatException("Invalid email format.");
    }

    private static bool IsValidEmail(string email)
    {
        return !string.IsNullOrWhiteSpace(email)
               && email.Contains("@")
               && email.Contains(".");
    }
}
public interface ISpecification<T>
{
    bool IsSatisfiedBy(T entity);
}
public class AgeSpecification : ISpecification<int>
{
    public bool IsSatisfiedBy(int age)
    {
        return age is >= 18 and <= 120;
    }
}
public class UserRegistrationRequest
{
    public string Username { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
    public int Age { get; set; }
    public string Email { get; set; } = string.Empty;
}

Summary

Refactoring Technique What It Improves
Guard Clauses Makes validation clearer
Extract Method Makes code easier to read
DTO Groups related input data
Validator Pattern Separates validation from business logic
Dependency Injection Improves testability
Value Objects Protects domain integrity
Specification Pattern Makes complex rules reusable
Open/Closed Principle Allows adding new rules without changing existing code
Result Pattern Works well for APIs and UI flows
Single Responsibility Principle Keeps classes focused

Conclusion

The best refactoring is not always the most advanced one.

Start simple.

First, use guard clauses.

Then extract repeated logic into methods.

After that, move validation into its own class.

When the domain becomes more important, consider value objects.

When business rules become more complex, consider the Specification Pattern.

And when working with APIs or UI forms, consider returning a result instead of throwing exceptions for expected validation failures.

A good refactor should make the code easier to understand without making it unnecessarily complicated.

More Posts

Decorate services in ASP.NET Core, step by step

Spyros - Nov 14, 2025

Understanding the Producer-Consumer Pattern in C#

Spyros - Apr 28

Understanding the Observer Pattern in C# with IObservable and IObserver

Spyros - Mar 11, 2025

Carter in a CQRS API, advantages and alternatives.

Spyros - Jan 7

How I Built a CQRS Approval Flow with MediatR, Carter, FluentValidation, and SQLite

Spyros - Dec 30, 2025
chevron_left

Commenters (This Week)

9 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!