Defensive by Design
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.
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
andage
.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 ofWBPerson
intoprintProfileForPerson:
. 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:
- Protocols to limit what's exposed.
- Scrutinising inputs.
- Readonly properties to control updates.
- 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.