Defensive by Design

06 Jan 2012 6 min read

Have you ever trusted the wrong person with something vulnerable, only to have them take advantage of it? Broken trust leaves you hurt and exposed. And while you don't stop trusting altogether, it takes longer for people to gain your trust. You become more careful about what you share, with whom, and when.

Code doesn't feel pain, but it can still be taken advantage of if it is overexposed. That's why it pays to be deliberate about what we reveal in the public interface of our types.

Photo of a child hiding their face

In this post, we'll explore how to write defensive code by applying three core principles: Polymorphism, Encapsulation, and Information Hiding.

Getting Defensive

Before we can start writing sensible, defensive code, let's ground ourselves in three key principles:

  • Polymorphism - allows different concrete types to be swapped without changing the code that depends on them.
  • Encapsulation - bundle data with the methods that operate on it, so other parts of the system interact only through that unit's interface.
  • Information Hiding - keep internal details private; expose only a stable, minimal interface.

Together, these principles form the foundation of defensive design. They let us create abstractions that are flexible enough to be reused anywhere, yet disciplined enough to maintain tight control through a stable, well-defined interface.

Our first defence is limiting visibility: expose only what other types truly need.

First Line of Defence: Protocols

Defensive design starts with limiting what a type exposes - the less that is known about the implementation of a type, the freer that type is to change.

Take the following example:

@interface WBPerson : NSObject

@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSInteger age;

- (void)updateWithName:(NSString *)name age:(NSInteger)age;
- (void)resetPerson;

@end

@implementation WBPerson

- (void)updateWithName:(NSString *)name age:(NSInteger)age {
    // Omitted method body
}

- (void)resetPerson {
    // Omitted method body
}

@end

Now imagine an WBProfileManager type that only needs to read a person's details:

@interface WBProfileManager : NSObject
- (void)printProfileForPerson:(WBPerson *)person;
@end

@implementation WBProfileManager

- (void)printProfileForPerson:(WBPerson *)person {
    NSLog(@"Name: %@, Age: %ld", person.name, (long)person.age);
}

@end

In the above code snippet, WBProfileManager gets access to all of WBPerson's methods, including resetPerson - even though it doesn't need them. That's unnecessary exposure, and it risks accidental misuse (e.g. wiping state).

We can reduce this exposure by introducing a protocol that exposes only what WBProfileManager actually needs:

@protocol WBPersonReadable 
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSInteger age;
@end

Now WBPerson can conform to this protocol:

@interface WBPerson : NSObject 

@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSInteger age;

- (void)updateWithName:(NSString *)name age:(NSInteger)age;
- (void)resetPerson;

@end

And WBProfileManager can depend on the protocol instead of the full type:

@interface WBProfileManager : NSObject
- (void)printProfileForPerson:(id)person;
@end

@implementation WBProfileManager

- (void)printProfileForPerson:(id)person {
    NSLog(@"Name: %@, Age: %ld", person.name, (long)person.age);
}

@end

With the above code snippet:

  • WBProfileManager now only sees what it needs: name and age.
  • WBPerson can still have richer behaviour (resetPerson) without leaking it to consumers that don't need it.
  • Any other type that conforms to WBPersonReadable can be swapped in, making the system more flexible.

We're using Method Dependency Injection here to pass an instance of WBPerson into printProfileForPerson:. For more on this technique, see: Let Dependency Injection Lead You to a Better Design.

That's our first line of defence: protocols give us a clean, minimal surface. Next, we'll look at how to defend against invalid input.

Second Line of Defence: Scrutinising What's Passed In

Defending by limiting what a type exposes is great, but we also need to scrutinise what data a type accepts to maintain good state hygiene.

Continuing with the WBPerson example, let's look at the implementation of updateWithName:age:

@interface WBPerson : NSObject

@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSInteger age;

- (void)updateWithName:(NSString *)name age:(NSInteger)age;
- (void)resetPerson;

@end

@implementation WBPerson

- (void)updateWithName:(NSString *)name age:(NSInteger)age {
    self.name = name;
    self.age = age;
}

// Omitted other methods

@end

From the above code snippet, you wouldn't know it, but there are business requirements around what are acceptable name and age values. A person must have a name value with at least two characters and an age value greater than zero. WBPerson assumes those rules will be enforced elsewhere, leaving it open to invalid input. That's risky - let's refactor to be more defensive:

@interface WBPerson : NSObject

@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSInteger age;

- (void)updateWithName:(NSString *)name age:(NSInteger)age;
- (void)resetPerson;

@end

@implementation WBPerson

- (void)updateWithName:(NSString *)name age:(NSInteger)age {
    if (name.length > 1) {
        self.name = name;
    }
    if (age > 0) {
        self.age = age;
    }
}

// Omitted other methods

@end

In the above code snippet, WBPerson now enforces its own rules: invalid input is rejected, keeping the instance in a valid state. By encapsulating validation inside the type, we protect it from misuse elsewhere in the system.

At the moment, WBPerson simply ignores invalid input, but in production standard code, you'd want to inform the caller of the rejection.

That's our second line of defence: guarding the gates against invalid input. Next, we'll look at how to control where updates happen.

Third Line of Defence: Control Where Updates Happen

Defending against poor input data is great, but it's only effective if there aren't backdoors. By marking properties as readonly, we prevent outside classes from directly setting their values. This prevention ensures updates can only happen through controlled methods.

Continuing with the WBPerson example - while we added validation into updateWithName:age:, there's nothing to stop another type from bypassing that logic and setting the properties directly. Let's fix that:

@interface WBPerson : NSObject

@property (nonatomic, copy, readonly) NSString *name;
@property (nonatomic, assign, readonly) NSInteger age;

- (void)updateWithName:(NSString *)name age:(NSInteger)age;
- (void)resetPerson;

@end

@implementation WBPerson {
    // Backing ivars for readonly properties
    NSString *_name;
    NSInteger _age;
}

- (void)updateWithName:(NSString *)name age:(NSInteger)age {
    if (name.length > 1) {
        _name = name;
    }

    if (age > 0) {
        _age = age;
    }
}

// Omitted other methods

@end

In the above code snippet, the properties are now readonly, so they can't be set directly. All updates must go through updateWithName:age:, which enforces our validation rules before touching the backing ivars.

That's our third line of defence: outside code can no longer bypass our safeguards. Next, we'll look at how to keep internal state hidden.

Fourth Line of Defence: Hiding Information

Defending by limiting state mutation is great, but not all state needs to be exposed in the first place.

Continuing with the WBPerson example, we can add a private property that tracks how many times this WBPerson instance has been updated:

@interface WBPerson : NSObject

@property (nonatomic, copy, readonly) NSString *name;
@property (nonatomic, assign, readonly) NSInteger age;

- (void)updateWithName:(NSString *)name age:(NSInteger)age;
- (void)resetPerson;

@end

@interface WBPerson () {
  // Backing ivars for readonly properties
  NSString *_name;
  NSInteger _age;
}

@property (nonatomic, assign) NSInteger updateCount;
@end

@implementation WBPerson

- (void)updateWithName:(NSString *)name age:(NSInteger)age {
    if (name.length > 1) {
        _name = name;
        self.updateCount++;
    }

    if (age > 0) {
        _age = age;
        self.updateCount++;
    }
}

- (void)resetPerson {
    _name = nil;
    _age = 0;
    self.updateCount = 0;
}

@end

The above code snippet demonstrates information hiding in action: the updateCount property is purely used internally, so it isn't exposed publicly.

That's our fourth line of defence: outside code can no longer spy on purely internal state.

Surveying the Defences

We've explored four lines of defence that help us control how other types interact with our own:

  1. Protocols to limit what's exposed.
  2. Scrutinising inputs.
  3. Readonly properties to control updates.
  4. Private state to hide sensitive details.

But these techniques do more than just prevent bugs - they fundamentally change how you think about design. When you approach each class as a fortress that must carefully control its boundaries, you naturally create more focused, cohesive types. You start asking the right questions: "What does this consumer actually need?", "How might this be misused?", and "What would happen if this state got corrupted?"

The paradox of defensive programming is that by restricting access, you actually increase flexibility. A well-defended interface can evolve internally without breaking its consumers. The WBPerson type we built could completely change how it stores names and ages - maybe switching to a database, adding caching, or implementing complex validation rules - and no consuming code would need to change.

Just as personal trust requires both vulnerability and boundaries, good software design requires both openness and protection. The interfaces you create today will outlive the implementations behind them. Make them count: expose what's necessary, protect what's fragile, and hide what's irrelevant. Your future self - and every developer who comes after you - will thank you for the care you took in drawing those lines.

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