Rehoming View Controller Responsibilities

05 Jan 2013 6 min read

Open almost any iOS project and you'll find at least one view controller that's become a hoarder’s den: networking responses, validation logic, and UI configuration all crammed together. This tangle makes our apps fragile, difficult to read, harder to test, and painful to extend.

The Single-Responsibility Principle gives us a way out. It states that any unit of work in your project should serve a single purpose. It's a simple idea, but view controllers are where it's most often ignored.

Photo of two dogs sitting in their garden

In this post, I'll show how to slim down these view controllers by rehoming functionality into focused types - resulting in cleaner code, clearer boundaries, and easier tests. With this change, our view controllers can focus on what they do best: coordinating between the user interface and the application logic.

Touring the Neighbourhood

Let's look at a simple example where a view controller is doing too much:

@interface WBRegistrationViewController : UIViewController

@property (nonatomic, copy) NSString *userID;

@end

@implementation WBRegistrationViewController

// Omitted other code

#pragma - UITextFieldDelegate

- (void)textFieldDidEndEditing:(UITextField *)textField
{
    // Get raw input, defaulting to an empty string if nil
    NSString *rawInput = textField.text ?: @"";

    // Remove *all* whitespace and newlines (edges + internal)
    NSCharacterSet *whitespace = [NSCharacterSet whitespaceAndNewlineCharacterSet];
    NSString *cleaned = [[rawInput componentsSeparatedByCharactersInSet:whitespace]
                         componentsJoinedByString:@""];

    // Check that the string only contains digits
    NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
    BOOL onlyDigits = ([cleaned rangeOfCharacterFromSet:nonDigits].location == NSNotFound);

    // Validate length and digit-only requirement
    if (onlyDigits && cleaned.length == 12) {
        self.userID = cleaned;
    } else {
        self.userID = nil;
    }
}

@end

At first glance, this WBRegistrationViewController looks fine - the view controller is responding to user input by validating that input and setting its initial state. But notice how much responsibility is packed into textFieldDidEndEditing:

  1. Validating input - checking that the input only contains numbers and is of length 12.
  2. Updating state - setting the userID property based on the output from the validation check.

That’s two jobs crammed into one method.

Now let's look at the unit tests required to exercise this validation logic:

@implementation WBRegistrationViewControllerTests

 - (void)test_givenAValidUserID_whenUserEntersTheUserID_thenUserIDShouldBeStoredInProperty
{
    WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];

    UITextField *textField = [[UITextField alloc] init];
    textField.text = @"012345678912";

    [sut textFieldDidEndEditing:textField];

    XCTAssertEqualObjects(sut.userID, @"012345678912", @"Property should be set to match the valid user ID")
}

- (void)test_givenAValidUserIDThatContainsSpaces_whenUserEntersTheUserID_thenUserIDShouldBeStoredInProperty
{
    WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];

    UITextField *textField = [[UITextField alloc] init];
    textField.text = @" 012345 678912 ";

    [sut textFieldDidEndEditing:textField];

    XCTAssertEqualObjects(sut.userID, @"012345678912", @"Property should be set to match the valid user ID")
}

- (void)test_givenAnInvalidUserIDThatIsTooShort_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
    WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];

    UITextField *textField = [[UITextField alloc] init];
    textField.text = @"1";

    [sut textFieldDidEndEditing:textField];

    XCTAssertNil(sut.userID, @"Property should be nil for an invalid user ID");
}

- (void)test_givenAnInvalidUserIDThatIsTooLong_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
    WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];

    UITextField *textField = [[UITextField alloc] init];
    textField.text = @"1000000000000000000";

    [sut textFieldDidEndEditing:textField];

    XCTAssertNil(sut.userID, @"Property should be nil for an invalid user ID");
}

- (void)test_givenAnInvalidUserIDThatIsEmpty_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
    WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];

    UITextField *textField = [[UITextField alloc] init];
    textField.text =  @"";

    [sut textFieldDidEndEditing:textField];

    XCTAssertNil(sut.userID, @"Property should be nil for an invalid user ID");
}

- (void)test_givenAnInvalidUserIDThatIsNil_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
    WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];

    UITextField *textField = [[UITextField alloc] init];
    textField.text = nil;

    [sut textFieldDidEndEditing:textField];

    XCTAssertNil(sut.userID, @"Property should be nil for an invalid user ID");
}

- (void)test_givenAnInvalidUserIDThatContainsLetters_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
    WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];

    UITextField *textField = [[UITextField alloc] init];
    textField.text = @"01234a67b912";

    [sut textFieldDidEndEditing:textField];

    XCTAssertNil(sut.userID, @"Property should be nil for an invalid user ID");
}

@end

These tests work, but notice the overhead to test the validation - we have to create a UITextField instance just to pass a string into the delegate method. We're testing validation through UIKit plumbing. What we really want to test is just the validation itself - but because it lives inside the view controller, we're forced to drag UIKit into the picture. By burying validation inside the view controller, we've made both the code and the tests heavier than they need to be.

Let's see how applying the Single-Responsibility Principle can simplify things by rehoming the validation rules into their own dedicated type - WBUserDetailsValidator.

Settling Functionality into Its New Home

Let's look at what WBUserDetailsValidator does:

@implementation WBUserDetailsValidator

- (BOOL)isValidUserID:(NSString *)userID
{
    // Get raw input, defaulting to an empty string if nil
    NSString *rawInput = userID ?: @"";

    // Remove *all* whitespace and newlines (edges + internal)
    NSCharacterSet *whitespace = [NSCharacterSet whitespaceAndNewlineCharacterSet];
    NSString *cleaned = [[rawInput componentsSeparatedByCharactersInSet:whitespace]
                         componentsJoinedByString:@""];

    // Check that the string only contains digits
    NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
    BOOL onlyDigits = ([cleaned rangeOfCharacterFromSet:nonDigits].location == NSNotFound);

    // Validate length and digit-only requirement
    return (onlyDigits && cleaned.length == 12);
}

@end

The above method isValidUserID: contains only the logic to know if a user ID is valid. WBUserDetailsValidator contains absolutely no knowledge about how it's used or where the data comes from - this is good, as this means that the tests don't need to contain that knowledge either:

@implementation WBUserDetailsValidatorTests


- (void)test_givenAValidUserID_whenisValidIsCalled_thenShouldReturnYES
{
    WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];

    BOOL isValid = [sut isValidUserID:@"012345678912"];

    XCTAssertTrue(isValid, @"True should be returned for a valid user ID")
}

- (void)test_givenAValidUserIDThatContainsSpaces_whenisValidIsCalled_thenUserIDShouldBeStoredInProperty
{
    WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];

    BOOL isValid = [sut isValidUserID:@" 012345 678912 "];

    XCTAssertTrue(isValid, @"True should be returned for a valid user ID")
}

- (void)test_givenAnInvalidUserIDThatIsTooShort_whenisValidIsCalled_thenShouldReturnNO
{
    WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];

    BOOL isValid = [sut isValidUserID:@"1"];

    XCTAssertFalse(isValid, @"False should be returned for an invalid user ID");
}

- (void)test_givenAnInvalidUserIDThatIsTooLong_whenisValidIsCalled_thenShouldReturnNO
{
    WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];

    BOOL isValid = [sut isValidUserID:@"1000000000000000000"];

    XCTAssertFalse(isValid, @"False should be returned for an invalid user ID");
}

- (void)test_givenAnInvalidUserIDThatIsEmpty_whenisValidIsCalled_thenShouldReturnNO
{
    WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];

    BOOL isValid = [sut isValidUserID: @""];

    XCTAssertFalse(isValid, @"False should be returned for an invalid user ID");
}

- (void)test_givenAnInvalidUserIDThatIsNil_whenisValidIsCalled_thenShouldReturnNO
{
    WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];

    BOOL isValid = [sut isValidUserID:nil];

    XCTAssertFalse(isValid, @"False should be returned for an invalid user ID");
}

- (void)test_givenAnInvalidUserIDThatContainsLetters_whenisValidIsCalled_thenShouldReturnNO
{
    WBUserDetailsValidator *sut = [[WBUserDetailsValidator alloc] init];

    BOOL isValid = [sut isValidUserID:@"01234a67b912"];

    XCTAssertFalse(isValid, @"False should be returned for an invalid user ID");
}

@end

The above tests are simpler than what we saw before in WBRegistrationViewController, as all UIKit plumbing has been removed.

Let's update WBRegistrationViewController to use WBUserDetailsValidator:

@interface WBRegistrationViewController : UIViewController

// Omitted other properties
@property (nonatomic, strong) WBUserDetailsValidator *validator;

@end

@implementation WBRegistrationViewController

// Omitted other code

#pragma - UITextFieldDelegate

- (void)textFieldDidEndEditing:(UITextField *)textField
{
    if ([self.validator isValidUserID:textField.text])
    {
        self.userID = textField.text;
    }
    else
    {
        self.userID = nil;
    }
}

@end

This change has significantly simplified textFieldDidEndEditing:, but we can push this further. Right now, our view controller tests would still need all 7 test cases because the paths through the method still depend on the validation rules - just more indirectly than before.

We can solve this with a bit of sprinkling Polymorphism via a Protocol.

Decorating with a Protocol

Rather than directly passing an instance of WBUserDetailsValidator into WBRegistrationViewController, we can pass a protocol that describes what a validator does to the view controller. This protocol will allow for the real validator to be passed in during production and a test double to be passed in during testing. This test double gives us complete control over validation outcomes, resulting in fewer unit tests needing to be written for textFieldDidEndEditing:.

This new protocol will mimic the current WBUserDetailsValidator interface:

@protocol WBUserDetailsValidating 

- (BOOL)isValidUserID:(NSString *)userID;

WBUserDetailsValidator then conforms to the protocol by:

@interface WBUserDetailsValidator : NSObject 
@end

Now that we have wrapped WBUserDetailsValidator in the WBUserDetailsValidating protocol, we change WBRegistrationViewController to use the protocol rather than the concrete type:

@interface WBRegistrationViewController : UIViewController

// Omitted other properties
@property (nonatomic, strong) id<WBUserDetailsValidating> validator;

@end

@implementation WBRegistrationViewController

// Same as before

@end

With that change in place, we can turn our attention to the unit tests.

First, we need to create our test double of WBUserDetailsValidating to control better what happens in the unit tests:

@interface WBStubUserDetailsValidator : NSObject 

@property (nonatomic, assign) BOOL stubbedResult;

@end

@implementation WBStubUserDetailsValidator

- (instancetype)init {
    self = [super init];

    if (self)
    {
        _stubbedResult = YES; // Default behaviour
    }

    return self;
}

- (BOOL)isValidUserID:(NSString *)userID
{
    return self.stubbedResult;
}

@end

WBStubUserDetailsValidator allows us to set the outcome of the validation without the validation actually needing to occur. To control the outcome, we set the stubbedResult property with the response that we want to return.

Now that we have our test double, let's make use of it:

@implementation WBRegistrationViewControllerTests

 - (void)test_givenAValidUserID_whenUserEntersTheUserID_thenUserIDShouldBeStoredInProperty
{
    WBStubUserDetailsValidator *validator = [[WBStubUserDetailsValidator alloc] init];
    validator.stubbedResult = YES; // returns YES when `isValidUserID` is called

    WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];
    sut.validator = validator;

    UITextField *textField = [[UITextField alloc] init];
    textField.text = @"test_user_id";

    [sut textFieldDidEndEditing:textField];

    XCTAssertEqualObjects(sut.userID, @"test_user_id", @"Property should be set to match the valid user ID")
}

- (void)test_givenAnInvalidUserID_whenUserEntersTheUserID_thenUserIDShouldNotBeStoredInProperty
{
    WBStubUserDetailsValidator *validator = [[WBStubUserDetailsValidator alloc] init];
    validator.stubbedResult = NO; // returns NO when `isValidUserID` is called

    WBRegistrationViewController *sut = [[WBRegistrationViewController alloc] init];
    sut.validator = validator;

    UITextField *textField = [[UITextField alloc] init];
    textField.text = @"test_user_id";

    [sut textFieldDidEndEditing:textField];

    XCTAssertNil(sut.userID, @"Property should be nil for an invalid user ID");
}

@end

To pass the test double, we are using a technique called Dependency Injection. If you want to learn more about the various types of Dependency Injection, read Let Dependency Injection Lead You to a Better Design.

Each test has slightly more code, but the number of tests required is down from 7 to 2. As our view controller no longer cares about what makes a string a valid user ID, it no longer has to test those paths; instead, it focuses on the two possible validation outcomes, YES or NO, and we only have to write tests for those two paths.

With validation separated into its own type, free of UIKit, we've simplified both the production code and test code by sticking to the Single-Responsibility Principle.

A Tidy, Peaceful Home

This example was just validating a user ID, but the same principle applies to networking, persistence, analytics, and beyond. Your view controllers don’t want to be hoarders. Help them declutter, give each unneeded responsibility a home of its own, and each view controller will be freer for it.

What do you think? Let me know by getting in touch on Mastodon or Bluesky.