Defensive Programming Principles: YAGNI, Invalid States, and Domain Purity
Defensive programming isn't just about handling edge cases—it's about designing systems that prevent entire classes of bugs from existing in the first place. Three fundamental principles stand out as particularly powerful: YAGNI (You Aren't Gonna Need It), making invalid states unrepresentable, and maintaining domain object purity. These principles, when applied consistently, create codebases that are not only more reliable but also more maintainable and easier to reason about.
The Foundation of Defensive Programming
Defensive programming has evolved beyond simple input validation and error checking. Modern defensive programming focuses on preventing problems by design rather than catching them after they occur. The three principles we'll explore work together to create a robust development approach:
- YAGNI prevents unnecessary complexity that breeds bugs
- Invalid state prevention uses type systems to eliminate entire classes of errors
- Domain purity maintains clear boundaries that prevent architectural decay
These aren't theoretical concepts—they're practical techniques with immediate benefits for any codebase, from small PHP applications to large-scale TypeScript systems.
YAGNI: Rejecting Unnecessary Complexity
YAGNI, coined by Martin Fowler and rooted in Extreme Programming, states that you should not add functionality until you actually need it. This principle directly combats over-engineering—the tendency to build "flexible" solutions for problems that don't exist.
Understanding YAGNI Through Pseudocode
The core concept is best understood by contrasting over-engineered and simple approaches. Here's how YAGNI violations typically manifest and how to apply the principle correctly:
# YAGNI Principle: Reject Unnecessary Complexity
# ❌ YAGNI Violation: Over-engineered for hypothetical needs
ABSTRACT CLASS CacheAdapter
ABSTRACT METHOD get(key) -> value
ABSTRACT METHOD set(key, value, ttl)
ABSTRACT METHOD delete(key)
ABSTRACT METHOD clear()
ABSTRACT METHOD exists(key) -> boolean
# "We might need these features someday..."
ABSTRACT METHOD getMultiple(keys) -> array
ABSTRACT METHOD setMultiple(items, ttl)
ABSTRACT METHOD increment(key, amount) -> integer
ABSTRACT METHOD addTags(key, tags)
ABSTRACT METHOD invalidateByTag(tag) -> count
ABSTRACT METHOD getStats() -> metrics
CLASS RedisCacheAdapter EXTENDS CacheAdapter
# Hundreds of lines implementing methods never used
CLASS FileCacheAdapter EXTENDS CacheAdapter
# Complex file locking for "portability"
CLASS DatabaseCacheAdapter EXTENDS CacheAdapter
# SQL queries for caching - defeats the purpose
CLASS CacheFactory
METHOD create(config) -> CacheAdapter
SWITCH config.type
CASE 'redis': RETURN new RedisCacheAdapter(config)
CASE 'file': RETURN new FileCacheAdapter(config)
# More unused implementations...
# Problems:
# - Months of development for unused features
# - Complex abstractions for simple needs
# - Maintenance burden of multiple implementations
# - Configuration complexity
# ✅ YAGNI Applied: Simple solution for actual requirement
CLASS SessionStore
PROPERTY redis_client
METHOD get(session_id) -> data
raw_data = redis_client.get("session:" + session_id)
RETURN raw_data ? parse_json(raw_data) : null
METHOD save(session_id, data, ttl = 3600)
json_data = to_json(data)
redis_client.setex("session:" + session_id, ttl, json_data)
METHOD delete(session_id)
redis_client.del("session:" + session_id)
METHOD exists(session_id) -> boolean
RETURN redis_client.exists("session:" + session_id)
# Benefits:
# - Solves immediate need with minimal complexity
# - Easy to understand and maintain
# - Quick to implement and deploy
# - Refactoring is straightforward when Redis support is actually needed
This pseudocode illustrates the fundamental YAGNI principle: focus on solving the actual requirement with the simplest possible solution. The over-engineered approach creates extensive abstractions for hypothetical future needs, while the YAGNI-compliant version addresses the immediate problem directly.
YAGNI in PHP: A Real-World Example
Here's how the YAGNI violation looks in actual PHP code—an over-engineered caching system built for requirements that don't exist:
<?php
// YAGNI Violation: Over-engineered "flexible" caching system
abstract class CacheAdapter
{
abstract public function get(string $key): mixed;
abstract public function set(string $key, mixed $value, int $ttl = 3600): void;
abstract public function delete(string $key): void;
abstract public function clear(): void;
abstract public function exists(string $key): bool;
// "We might need these features someday..."
abstract public function getMultiple(array $keys): array;
abstract public function setMultiple(array $items, int $ttl = 3600): void;
abstract public function deleteMultiple(array $keys): void;
abstract public function increment(string $key, int $value = 1): int;
abstract public function decrement(string $key, int $value = 1): int;
abstract public function getTtl(string $key): ?int;
abstract public function expire(string $key, int $ttl): void;
abstract public function addTags(string $key, array $tags): void;
abstract public function invalidateByTag(string $tag): int;
abstract public function getStats(): array;
}
// Multiple implementations "for future flexibility"
class RedisCacheAdapter extends CacheAdapter
{
// 200+ lines of Redis-specific implementation
// Most methods never used in production
}
class FileCacheAdapter extends CacheAdapter
{
// Complex file locking, serialization, cleanup
// Slower than Redis but "more portable"
}
class DatabaseCacheAdapter extends CacheAdapter
{
// SQL queries for caching - defeating the purpose
}
// Complex configuration system
interface CacheConfigurationInterface
{
public function getDefaultTtl(): int;
public function getNamespace(): string;
public function getSerializationFormat(): string;
public function enableCompression(): bool;
public function getCompressionLevel(): int;
}
class ConfigurableRedisCache extends RedisCacheAdapter
{
private CacheConfigurationInterface $config;
private SerializerInterface $serializer;
private CompressionInterface $compressor;
// Months of development for features never requested
}
This represents months of development time invested in abstract base classes, multiple implementations, factory patterns, and configuration systems—all for a simple session storage need.
The YAGNI-compliant PHP approach focuses on the actual requirement:
<?php
// YAGNI Applied: Simple Redis solution for the actual requirement
class UserSessionService
{
private Redis $redis;
public function __construct(Redis $redis)
{
$this->redis = $redis;
}
public function get(string $sessionId): ?array
{
$data = $this->redis->get("session:$sessionId");
return $data ? json_decode($data, true) : null;
}
public function save(string $sessionId, array $data, int $ttl = 3600): void
{
$this->redis->setex("session:$sessionId", $ttl, json_encode($data, JSON_THROW_ON_ERROR));
}
public function delete(string $sessionId): void
{
$this->redis->del("session:$sessionId");
}
public function exists(string $sessionId): bool
{
return (bool) $this->redis->exists("session:$sessionId");
}
}
// That's it! No abstractions, no "flexible" interfaces, no factory patterns.
// Just a focused solution that does exactly what's needed:
// - Store user sessions in Redis with TTL
// - Retrieve and delete sessions
// - Check if session exists
//
// When requirements change (like adding session metadata, different TTLs,
// or distributed session support), THEN we refactor based on concrete needs.
This simple implementation solves the immediate need without unnecessary abstraction. The key insight from YAGNI's definition is that when additional caching features are actually needed, refactoring this code is straightforward—and you'll have concrete requirements to guide the design.
YAGNI in Infrastructure as Code
YAGNI applies equally well to infrastructure automation. Compare these Ansible playbooks:
# Bad: YAGNI violation - Over-engineered "flexible" deployment
---
- name: Deploy application with excessive flexibility
hosts: "{{ target_environment | default('production') }}"
vars:
deployment_strategies:
- blue_green
- rolling
- canary
- recreate
supported_databases:
- mysql
- postgresql
- mongodb
- redis
notification_channels:
- slack
- email
- webhook
- sms
monitoring_integrations:
- datadog
- newrelic
- prometheus
- splunk
tasks:
- name: Determine deployment strategy
set_fact:
deploy_strategy: "{{ deployment_strategy | default('rolling') }}"
when: deploy_strategy is not defined
- name: Configure database based on type
include_tasks: "database/{{ database_type | default('mysql') }}.yml"
loop: "{{ supported_databases }}"
when: database_type == item
- name: Setup monitoring for each integration
include_tasks: "monitoring/{{ item }}.yml"
loop: "{{ monitoring_integrations }}"
when: monitoring_enabled | default(false)
- name: Deploy using strategy
include_tasks: "deploy/{{ deploy_strategy }}.yml"
- name: Send notifications
include_tasks: "notifications/{{ item }}.yml"
loop: "{{ notification_channels }}"
when: notifications_enabled | default(false)
# Good: YAGNI applied - Simple, focused deployment
---
- name: Deploy PHP application
hosts: production
tasks:
- name: Pull latest code
git:
repo: https://github.com/company/app.git
dest: /var/www/app
version: "{{ git_tag }}"
- name: Install dependencies
composer:
command: install
working_dir: /var/www/app
no_dev: true
- name: Run database migrations
command: php artisan migrate --force
args:
chdir: /var/www/app
- name: Restart PHP-FPM
service:
name: php8.3-fpm
state: restarted
- name: Clear application cache
command: php artisan cache:clear
args:
chdir: /var/www/app
The "flexible" playbook introduces complexity for deployment strategies, database types, and monitoring systems that aren't currently needed. The simple version accomplishes the actual goal with clear, maintainable code.
When YAGNI Doesn't Apply
As Martin Fowler clarifies, YAGNI doesn't apply to efforts that make software easier to modify. Good architecture, clean code practices, and refactoring support YAGNI by keeping code malleable for future changes.
"Yagni only applies to capabilities built into the software to support a presumptive feature, it does not apply to effort to make the software easier to modify." — Martin Fowler
Make Invalid States Unrepresentable
This principle, popularized in functional programming, uses type systems to prevent invalid data from being represented in your program. When implemented correctly, the compiler prevents entire classes of runtime errors.
Understanding Type Safety Through Pseudocode
The core concept involves using type systems to make invalid data combinations impossible to represent. Here's how weak typing creates problems and how proper type design solves them:
# Invalid States Prevention: Type Safety by Design
# ❌ Bad: Invalid states are representable - runtime errors waiting to happen
CLASS UserAccount
PROPERTY email AS string # Any string allowed!
PROPERTY status AS string # "active", "deleted", "banana" - all valid
PROPERTY password_hash AS string # Could be plaintext by accident
PROPERTY verified_at AS datetime # Could be null when shouldn't be
METHOD can_login() -> boolean
# Must defensively check all possible invalid combinations
IF status NOT IN ['active', 'verified', 'pending']
RETURN false
IF password_hash IS null OR password_hash IS empty
RETURN false
IF status = 'pending' AND verified_at IS null
RETURN false
# Many more defensive checks...
RETURN true
# Problems with weak typing:
user = new UserAccount()
user.email = "definitely-not-an-email" # Compiles fine!
user.status = "INVALID_STATUS" # Runtime bug waiting to happen
user.password_hash = "plaintext-password" # Security vulnerability!
# ✅ Good: Invalid states are unrepresentable through type design
ENUM UserStatus
ACTIVE
INACTIVE
SUSPENDED
PENDING_VERIFICATION
DELETED
# Smart value types with built-in validation
TYPE Email = string WITH CONSTRAINT valid_email_format(value)
TYPE PasswordHash = string WITH CONSTRAINT valid_bcrypt_format(value)
TYPE UserId = string WITH CONSTRAINT non_empty_alphanumeric(value)
CLASS UserAccount
PROPERTY id AS UserId
PROPERTY email AS Email
PROPERTY status AS UserStatus
PROPERTY password_hash AS PasswordHash OPTIONAL
PROPERTY verified_at AS datetime OPTIONAL
METHOD can_login() -> boolean
# Type system guarantees status is valid - no defensive checks needed!
SWITCH status
CASE ACTIVE:
RETURN password_hash IS NOT null
CASE PENDING_VERIFICATION:
RETURN password_hash IS NOT null AND verified_at IS NOT null
CASE INACTIVE, SUSPENDED, DELETED:
RETURN false
# Smart constructors enforce business invariants
FUNCTION create_user(id_str, email_str, password_str) -> UserAccount
# These validations throw exceptions for invalid inputs
id = UserId.from(id_str) # Validates format
email = Email.from(email_str) # Validates email format
hash = PasswordHash.from_password(password_str) # Hashes securely
# Constructor call with guaranteed-valid values
RETURN new UserAccount(id, email, PENDING_VERIFICATION, hash, null)
# Benefits:
# - Invalid data cannot be represented in the type system
# - Business logic becomes simpler - no defensive validation needed
# - Compiler catches type errors at build time
# - Refactoring is safe - type changes force code updates
This pseudocode demonstrates the fundamental shift from runtime validation to compile-time safety. When invalid states are unrepresentable in the type system, entire categories of bugs become impossible. Business logic becomes simpler because it doesn't need defensive validation—the types guarantee data integrity.
PHP 8.4: Type-Safe Domain Modeling
Here's how weak typing creates problems in traditional object-oriented PHP code:
<?php
// Bad: Invalid states are representable
class User
{
public string $id;
public string $email;
public string $status; // Can be anything!
public ?string $hashedPassword;
public ?DateTime $emailVerifiedAt;
public function __construct(string $id, string $email, string $status = 'active')
{
$this->id = $id;
$this->email = $email;
$this->status = $status; // No validation!
$this->hashedPassword = null;
$this->emailVerifiedAt = null;
}
public function isActive(): bool
{
// What if status is "ACTIVE", "Active", "enabled", "1", or gibberish?
return strtolower($this->status) === 'active';
}
public function canLogin(): bool
{
// Complex business logic with many edge cases
if (!$this->isActive()) return false;
if (!$this->hashedPassword) return false;
if ($this->status === 'pending_verification' && !$this->emailVerifiedAt) {
return false;
}
if ($this->status === 'suspended' || $this->status === 'banned') {
return false;
}
// Forgot to handle 'inactive', 'deleted', or typos!
return true;
}
}
// Problems this creates:
$user = new User('123', 'test@example.com');
$user->status = 'DEFINITELY_NOT_VALID_STATUS'; // Compiles fine!
$user->hashedPassword = 'plaintext-password'; // Oops!
$user->emailVerifiedAt = new DateTime('invalid date'); // Runtime error waiting to happen
// What happens when we check?
var_dump($user->canLogin()); // Returns true! Security vulnerability!
This code has multiple problems: the status field accepts any string, passwords might not be hashed, and the business logic must handle all possible invalid combinations. Every method that uses a User object must include defensive checks for malformed data.
PHP 8.4's enums, readonly classes, property hooks, and asymmetric visibility enable safer domain modeling:
<?php
// Good: Invalid states are unrepresentable through design
enum UserStatus: string
{
case ACTIVE = 'active';
case INACTIVE = 'inactive';
case SUSPENDED = 'suspended';
case PENDING_VERIFICATION = 'pending_verification';
case DELETED = 'deleted';
}
readonly class User
{
public function __construct(
public string $id,
public string $email,
public UserStatus $status,
public ?string $passwordHash = null,
public ?DateTimeImmutable $emailVerifiedAt = null
) {
// Validate at construction - fail fast
if (empty($id)) {
throw new InvalidArgumentException('User ID cannot be empty');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email format');
}
if ($passwordHash !== null && strlen($passwordHash) < 60) {
throw new InvalidArgumentException('Invalid password hash format');
}
}
public static function create(string $id, string $email, string $password): self
{
if (strlen($password) < 8) {
throw new InvalidArgumentException('Password must be at least 8 characters');
}
return new self(
$id,
$email,
UserStatus::PENDING_VERIFICATION,
password_hash($password, PASSWORD_DEFAULT)
);
}
public function canLogin(): bool
{
return match($this->status) {
UserStatus::ACTIVE => $this->passwordHash !== null,
UserStatus::PENDING_VERIFICATION =>
$this->passwordHash !== null && $this->emailVerifiedAt !== null,
UserStatus::INACTIVE,
UserStatus::SUSPENDED,
UserStatus::DELETED => false,
};
}
public function verify(?DateTimeImmutable $verifiedAt = null): self
{
if ($this->status !== UserStatus::PENDING_VERIFICATION) {
throw new DomainException('User must be pending verification');
}
return new self(
$this->id,
$this->email,
UserStatus::ACTIVE,
$this->passwordHash,
$verifiedAt ?? new DateTimeImmutable()
);
}
}
// Clean usage - no over-engineered value objects
$user = User::create('user123', 'user@example.com', 'secure-password');
$verifiedUser = $user->verify();
// Invalid states are impossible:
// - Empty ID/email caught at construction
// - Invalid email format caught at construction
// - Weak passwords caught at creation
// - Status transitions controlled through methods
// - Match expression ensures exhaustive status handling
Now invalid states are literally impossible to construct. The enum restricts status values, constructor validation ensures data integrity, readonly properties prevent mutation, and the match expression ensures exhaustive handling of all cases. This approach eliminates an entire category of bugs at compile time.
PHP 8.4 Property Hooks for Defensive Programming
Property hooks, introduced in PHP 8.4, revolutionize how we implement defensive validation by moving it directly into the type system:
readonly class Money
{
public int $amount {
set {
if ($value < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
$this->amount = $value;
}
}
public string $currency {
set {
if (!in_array($value, ['USD', 'EUR', 'GBP'])) {
throw new InvalidArgumentException('Unsupported currency');
}
$this->currency = strtoupper($value);
}
}
public function __construct(int $amount, string $currency)
{
$this->amount = $amount; // Triggers validation hook
$this->currency = $currency; // Triggers validation hook
}
}
// Property hooks ensure validation happens automatically
$price = new Money(1000, 'usd'); // Currency normalized to 'USD'
// $invalid = new Money(-50, 'USD'); // Throws InvalidArgumentException
Property hooks eliminate the need for separate validation methods or complex constructor logic. The validation is part of the property definition, making it impossible to bypass and reducing the surface area for bugs.
Asymmetric Visibility for Immutable Public APIs
Asymmetric visibility, also new in PHP 8.4, allows properties to be publicly readable but privately writable, creating truly immutable public interfaces without sacrificing internal flexibility:
class OrderLine
{
// Publicly readable, privately settable
public private(set) ProductId $productId;
public private(set) int $quantity;
public private(set) Money $unitPrice;
// Computed property - publicly readable only
public Money $totalPrice {
get => new Money(
$this->quantity * $this->unitPrice->amount,
$this->unitPrice->currency
);
}
public function __construct(ProductId $productId, int $quantity, Money $unitPrice)
{
if ($quantity <= 0) {
throw new InvalidArgumentException('Quantity must be positive');
}
$this->productId = $productId;
$this->quantity = $quantity;
$this->unitPrice = $unitPrice;
}
public function changeQuantity(int $newQuantity): OrderLine
{
return new OrderLine($this->productId, $newQuantity, $this->unitPrice);
}
}
// External code can read but not modify properties
$line = new OrderLine($product, 5, new Money(1000, 'USD'));
echo $line->quantity; // Works: 5
echo $line->totalPrice->amount; // Works: 5000 (computed property)
// $line->quantity = 10; // Compile error: property is private(set)
This pattern prevents the common mistake of accidentally mutating objects that should be immutable, while providing a clean, readable public API. The computed properties also demonstrate how property hooks can create derived values without exposing internal state management complexity.
TypeScript's Nominal Typing
TypeScript's structural typing can be enhanced with branded types to achieve similar safety:
// Bad: Invalid states representable
interface UserBad {
id: string;
email: string;
status: string; // Any string allowed!
hashedPassword?: string;
emailVerifiedAt?: Date;
}
// Problems with this approach:
const badUser: UserBad = {
id: "123",
email: "user@example.com",
status: "TOTALLY_INVALID_STATUS", // Compiles fine
hashedPassword: "plaintext", // Not actually hashed!
};
function canLoginBad(user: UserBad): boolean {
// Need to handle all possible string values
const validStatuses = ['active', 'pending', 'verified'];
if (!validStatuses.includes(user.status.toLowerCase())) {
return false;
}
return user.hashedPassword !== undefined;
}
// Good: Invalid states unrepresentable
type UserStatus =
| 'active'
| 'inactive'
| 'suspended'
| 'pending_verification'
| 'deleted';
type UserId = string & { readonly brand: unique symbol };
type Email = string & { readonly brand: unique symbol };
type HashedPassword = string & { readonly brand: unique symbol };
interface UserGood {
readonly id: UserId;
readonly email: Email;
readonly status: UserStatus;
readonly hashedPassword?: HashedPassword;
readonly emailVerifiedAt?: Date;
}
// Smart constructors ensure validity
function createUserId(value: string): UserId {
if (!value.trim()) {
throw new Error('User ID cannot be empty');
}
return value as UserId;
}
function createEmail(value: string): Email {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
throw new Error('Invalid email format');
}
return value as Email;
}
function createHashedPassword(plaintext: string): HashedPassword {
if (plaintext.length < 8) {
throw new Error('Password must be at least 8 characters');
}
// In production: use bcrypt, scrypt, or Argon2
const hashed = `$2b$10$${btoa(plaintext + Math.random())}`;
return hashed as HashedPassword;
}
function canLoginGood(user: UserGood): boolean {
// TypeScript ensures status is valid
switch (user.status) {
case 'active':
return user.hashedPassword !== undefined;
case 'pending_verification':
return user.hashedPassword !== undefined &&
user.emailVerifiedAt !== undefined;
case 'inactive':
case 'suspended':
case 'deleted':
return false;
// TypeScript ensures exhaustive matching
}
}
// Usage - invalid states are impossible
const goodUser: UserGood = {
id: createUserId("user-123"),
email: createEmail("user@example.com"),
status: 'active', // Only valid statuses allowed
hashedPassword: createHashedPassword("secretpassword")
};
// These would cause compile/runtime errors:
// status: 'invalid' // TypeScript compile error
// email: 'not-email' // Runtime error from createEmail
// id: '' // Runtime error from createUserId
The branded types and union types prevent invalid states while maintaining TypeScript's ergonomics. Smart constructors ensure validation happens at object creation, not throughout the application.
Benefits in Practice
As noted in GeekLaunch's analysis, this approach provides several key benefits:
- Compile-time safety: Invalid data combinations cannot be created
- Simplified logic: Business methods don't need defensive validation
- Self-documenting code: Types express business rules clearly
- Refactoring confidence: Type changes force updates to all affected code
Domain Object Purity
Domain object purity, a cornerstone of Domain-Driven Design, keeps business logic separate from infrastructure concerns. Pure domain objects depend only on other domain objects and primitive types, never on external systems like databases, APIs, or frameworks.
Understanding Domain Purity Through Pseudocode
Domain purity is about architectural separation—keeping business logic isolated from infrastructure concerns. Here's how impure domain objects create problems and how clean boundaries solve them:
# Domain Purity: Separating Business Logic from Infrastructure
# ❌ Bad: Domain object polluted with infrastructure dependencies
CLASS Order
PROPERTY order_id
PROPERTY customer_id
PROPERTY items AS array
PROPERTY total AS money
# Infrastructure dependencies polluting domain object!
PROPERTY database # Database access
PROPERTY email_service # Email sending
PROPERTY payment_gateway # Payment processing
PROPERTY inventory_api # External API calls
METHOD process()
# Domain logic mixed with database queries
FOR EACH item IN items
stock = database.query("SELECT quantity FROM inventory WHERE product_id = ?", item.product_id)
IF stock < item.quantity
THROW InsufficientInventoryError
# Payment processing mixed into domain logic
result = payment_gateway.charge(total, customer_id)
IF NOT result.success
THROW PaymentFailedError
# Email sending in domain object - wrong architectural layer!
email_service.send_confirmation(customer_id, this)
# Direct database mutation from domain object
database.execute("UPDATE orders SET status='completed' WHERE id = ?", order_id)
# Problems:
# - Hard to test (requires database, payment service, email service)
# - Mixed concerns (business logic + infrastructure)
# - Tight coupling to external systems
# - Cannot reuse domain logic in different contexts
# ✅ Good: Pure domain object with clean boundaries
ENUM OrderStatus
PENDING
PROCESSING
COMPLETED
FAILED
# Domain events describe what happened - no side effects
RECORD OrderProcessed
order_id
customer_id
total
processed_at
RECORD OrderFailed
order_id
reason
failed_at
CLASS Order
PROPERTY order_id
PROPERTY customer_id
PROPERTY items AS array
PROPERTY total AS money
PROPERTY status AS OrderStatus = PENDING
# Pure business logic - no infrastructure dependencies
METHOD can_process(inventory_checker) -> boolean
FOR EACH item IN items
IF NOT inventory_checker.has_sufficient_stock(item.product_id, item.quantity)
RETURN false
RETURN status = PENDING
METHOD process() -> OrderProcessed
# Pure state transition - no side effects!
IF status != PENDING
THROW DomainException("Order must be pending to process")
status = PROCESSING
# Return domain event instead of performing side effects
RETURN new OrderProcessed(order_id, customer_id, total, current_time())
METHOD fail(reason) -> OrderFailed
status = FAILED
RETURN new OrderFailed(order_id, reason, current_time())
# Application service orchestrates infrastructure concerns
CLASS ProcessOrderService
PROPERTY order_repository
PROPERTY inventory_service
PROPERTY payment_service
PROPERTY event_dispatcher
METHOD execute(order_id)
# Load domain object from repository
order = order_repository.find_by_id(order_id)
# Check business rules using injected service
IF NOT order.can_process(inventory_service)
failed_event = order.fail("Insufficient inventory")
order_repository.save(order)
event_dispatcher.publish(failed_event) # Email sent by event handler
RETURN
TRY
# Handle infrastructure concerns at application layer
payment_service.charge(order.total, order.customer_id)
processed_event = order.process() # Pure domain transition
order_repository.save(order)
event_dispatcher.publish(processed_event) # Triggers confirmation email
CATCH PaymentError as error
failed_event = order.fail("Payment failed: " + error.message)
order_repository.save(order)
event_dispatcher.publish(failed_event)
# Event handlers manage side effects separately from domain logic
CLASS OrderCompletedHandler
PROPERTY email_service
PROPERTY analytics_service
METHOD handle(order_processed_event)
# Infrastructure concerns handled separately
customer = customer_repository.find_by_id(event.customer_id)
email_service.send_confirmation(customer.email, event)
analytics_service.track_conversion(event.order_id, event.total)
# Benefits:
# - Domain logic is testable in isolation (no mocking required)
# - Business rules are clear and focused
# - Infrastructure can change without affecting domain logic
# - Domain objects are reusable across different application contexts
This pseudocode illustrates the core principle: domain objects should contain only business logic and state transitions. Infrastructure concerns like database access, external APIs, and side effects are handled by application services. This separation makes code testable, maintainable, and adaptable to infrastructure changes.
PHP Example: From Impure to Pure Domain Objects
Many applications suffer from domain objects that are tightly coupled to infrastructure. Here's a typical violation of domain purity:
<?php
// Bad: Domain object tightly coupled to infrastructure
class Order
{
public function __construct(
private string $id,
private string $customerId,
private array $items,
private Money $total,
private PDO $db,
private LoggerInterface $logger,
private MailerInterface $mailer,
private PaymentGateway $paymentGateway
) {}
public function process(): void
{
// Domain logic mixed with infrastructure concerns
$this->logger->info("Processing order {$this->id}");
// Database query in domain object!
$stmt = $this->db->prepare('SELECT * FROM inventory WHERE product_id = ?');
foreach ($this->items as $item) {
$stmt->execute([$item['product_id']]);
$inventory = $stmt->fetch();
if ($inventory['quantity'] < $item['quantity']) {
$this->logger->error("Insufficient inventory for {$item['product_id']}");
throw new InsufficientInventoryException();
}
}
// Payment processing mixed in
$paymentResult = $this->paymentGateway->charge(
$this->total->getAmount(),
$this->customerId
);
if (!$paymentResult->isSuccessful()) {
$this->logger->error("Payment failed for order {$this->id}");
throw new PaymentFailedException();
}
// Email sending in domain object!
$this->mailer->send(
'order-confirmation',
$this->getCustomerEmail(),
['order' => $this]
);
// Update database
$this->db->prepare('UPDATE orders SET status = ? WHERE id = ?')
->execute(['completed', $this->id]);
$this->logger->info("Order {$this->id} completed");
}
private function getCustomerEmail(): string
{
// More database access in domain object!
$stmt = $this->db->prepare('SELECT email FROM customers WHERE id = ?');
$stmt->execute([$this->customerId]);
return $stmt->fetchColumn();
}
}
// Problems:
// 1. Domain object depends on 4 external services
// 2. Business logic is mixed with infrastructure concerns
// 3. Hard to test - requires database, mailer, payment gateway
// 4. Violates Single Responsibility Principle
// 5. Changes to infrastructure affect domain logic
This Order class violates domain purity by depending on four external services. The business logic is scattered across database queries, payment processing, and email sending. Testing requires mocking multiple services, and changes to infrastructure affect domain logic.
The pure approach separates domain logic from infrastructure concerns:
<?php
// Good: Pure domain object with clean boundaries
readonly class Order
{
public function __construct(
private OrderId $id,
private CustomerId $customerId,
private OrderItems $items,
private Money $total,
private OrderStatus $status = OrderStatus::PENDING
) {}
public function canBeProcessed(InventoryService $inventory): bool
{
foreach ($this->items as $item) {
if (!$inventory->hasAvailableQuantity($item->productId(), $item->quantity())) {
return false;
}
}
return true;
}
public function process(): OrderProcessed
{
if ($this->status !== OrderStatus::PENDING) {
throw new DomainException('Order can only be processed when pending');
}
$this->status = OrderStatus::PROCESSING;
return new OrderProcessed(
$this->id,
$this->customerId,
$this->total,
new DateTimeImmutable()
);
}
public function complete(): OrderCompleted
{
if ($this->status !== OrderStatus::PROCESSING) {
throw new DomainException('Order must be processing to complete');
}
$this->status = OrderStatus::COMPLETED;
return new OrderCompleted(
$this->id,
$this->customerId,
$this->items,
$this->total,
new DateTimeImmutable()
);
}
public function fail(string $reason): OrderFailed
{
$this->status = OrderStatus::FAILED;
return new OrderFailed(
$this->id,
$this->customerId,
$reason,
new DateTimeImmutable()
);
}
// Pure getters - no external dependencies
public function id(): OrderId { return $this->id; }
public function customerId(): CustomerId { return $this->customerId; }
public function items(): OrderItems { return $this->items; }
public function total(): Money { return $this->total; }
public function status(): OrderStatus { return $this->status; }
}
// Application service handles orchestration
class ProcessOrderService
{
public function __construct(
private OrderRepository $orders,
private InventoryService $inventory,
private PaymentService $payment,
private EventDispatcher $events,
private LoggerInterface $logger
) {}
public function execute(ProcessOrderCommand $command): void
{
$order = $this->orders->findById($command->orderId());
if (!$order->canBeProcessed($this->inventory)) {
$failed = $order->fail('Insufficient inventory');
$this->orders->save($order);
$this->events->dispatch($failed);
return;
}
try {
$processed = $order->process();
$this->events->dispatch($processed);
$this->payment->charge($order->total(), $order->customerId());
$completed = $order->complete();
$this->orders->save($order);
$this->events->dispatch($completed);
} catch (PaymentException $e) {
$failed = $order->fail('Payment failed: ' . $e->getMessage());
$this->orders->save($order);
$this->events->dispatch($failed);
$this->logger->error('Order payment failed', [
'order_id' => $order->id()->value(),
'error' => $e->getMessage()
]);
}
}
}
// Event handlers manage side effects
class OrderCompletedHandler
{
public function __construct(
private MailerInterface $mailer,
private CustomerRepository $customers
) {}
public function handle(OrderCompleted $event): void
{
$customer = $this->customers->findById($event->customerId());
$this->mailer->send(
'order-confirmation',
$customer->email()->value(),
[
'order_id' => $event->orderId()->value(),
'items' => $event->items()->toArray(),
'total' => $event->total()->format()
]
);
}
}
The pure Order object contains only business logic and state transitions. It returns domain events that describe what happened, allowing application services to handle infrastructure concerns. This separation, as described in Vladimir Khorikov's analysis, makes the code easier to test, understand, and modify.
The Role of Application Services
Application services orchestrate domain objects and infrastructure, maintaining the clean separation:
- Domain objects contain business rules and state transitions
- Application services coordinate between domain and infrastructure
- Event handlers manage side effects like emails and notifications
- Repositories handle data persistence without polluting domain logic
Benefits of Domain Purity
As outlined in Enterprise Craftsmanship's analysis, pure domain models provide several advantages:
- Testability: Domain logic can be tested in isolation
- Clarity: Business rules are expressed clearly without infrastructure noise
- Flexibility: Infrastructure can change without affecting business logic
- Reusability: Pure domain objects work in any context
Cross-Language Application
These principles aren't language-specific. While we've focused on pseudocode and PHP examples to demonstrate the core concepts, the same defensive patterns apply across different technologies. Here's how to implement these principles in other environments.
Bash Scripting with Defensive Patterns
Even in Bash scripting, we can apply defensive programming principles by validating inputs, handling errors explicitly, and keeping functions focused on single responsibilities:
#!/bin/bash
# Bad: Non-defensive bash script
deploy_app() {
cd /var/www/app
git pull origin main
composer install --no-dev
php artisan migrate --force
sudo systemctl restart php8.3-fpm
php artisan cache:clear
}
# Good: Defensive bash script with proper error handling
deploy_app() {
local app_dir="/var/www/app"
local php_service="php8.3-fpm"
# Validate environment
if [[ ! -d "$app_dir" ]]; then
echo "Error: Application directory does not exist: $app_dir" >&2
return 1
fi
if ! command -v php >/dev/null 2>&1; then
echo "Error: PHP is not installed or not in PATH" >&2
return 1
fi
if ! command -v composer >/dev/null 2>&1; then
echo "Error: Composer is not installed or not in PATH" >&2
return 1
fi
# Change to app directory safely
if ! cd "$app_dir"; then
echo "Error: Could not change to directory: $app_dir" >&2
return 1
fi
echo "Pulling latest code..."
if ! git pull origin main; then
echo "Error: Git pull failed" >&2
return 1
fi
echo "Installing dependencies..."
if ! composer install --no-dev --optimize-autoloader; then
echo "Error: Composer install failed" >&2
return 1
fi
echo "Running database migrations..."
if ! php artisan migrate --force; then
echo "Error: Database migration failed" >&2
return 1
fi
echo "Restarting PHP-FPM..."
if ! sudo systemctl restart "$php_service"; then
echo "Error: Could not restart $php_service" >&2
return 1
fi
echo "Clearing application cache..."
if ! php artisan cache:clear; then
echo "Error: Cache clear failed" >&2
return 1
fi
echo "Deployment completed successfully!"
return 0
}
# YAGNI Applied: Simple backup script
backup_database() {
local db_name="${1:-app_production}"
local backup_dir="/backups/mysql"
local timestamp=$(date +%Y%m%d_%H%M%S)
local backup_file="$backup_dir/${db_name}_${timestamp}.sql"
# Simple validation - only what's actually needed
[[ -z "$db_name" ]] && { echo "Database name required" >&2; return 1; }
[[ ! -d "$backup_dir" ]] && mkdir -p "$backup_dir"
# Perform backup
if mysqldump "$db_name" > "$backup_file"; then
echo "Backup created: $backup_file"
# Keep only last 7 days of backups
find "$backup_dir" -name "${db_name}_*.sql" -mtime +7 -delete
return 0
else
echo "Backup failed" >&2
return 1
fi
}
# Make Invalid States Unrepresentable: File permissions
secure_file_permissions() {
local file="$1"
local expected_owner="$2"
local expected_mode="$3"
# Validate inputs exist
if [[ -z "$file" || -z "$expected_owner" || -z "$expected_mode" ]]; then
echo "Usage: secure_file_permissions <file> <owner> <mode>" >&2
return 1
fi
# File must exist
if [[ ! -f "$file" ]]; then
echo "Error: File does not exist: $file" >&2
return 1
fi
# Get current file stats
local current_owner current_mode
current_owner=$(stat -c "%U:%G" "$file")
current_mode=$(stat -c "%a" "$file")
# Fix ownership if incorrect
if [[ "$current_owner" != "$expected_owner" ]]; then
echo "Fixing ownership: $file ($current_owner -> $expected_owner)"
chown "$expected_owner" "$file" || return 1
fi
# Fix permissions if incorrect
if [[ "$current_mode" != "$expected_mode" ]]; then
echo "Fixing permissions: $file ($current_mode -> $expected_mode)"
chmod "$expected_mode" "$file" || return 1
fi
echo "File security validated: $file"
return 0
}
# Usage with proper error handling
main() {
if ! deploy_app; then
echo "Deployment failed, aborting" >&2
exit 1
fi
if ! backup_database "my_app"; then
echo "Backup failed, but deployment succeeded" >&2
exit 2
fi
echo "All operations completed successfully"
}
Notice how even in a shell script, we validate inputs early, handle errors explicitly with proper exit codes, and structure functions to have single responsibilities. The YAGNI principle applies here too—this script does exactly what's needed without unnecessary complexity.
Complete Reference: All Principles in Pseudocode
For a comprehensive view of how all three principles work together conceptually across any programming language:
# Defensive Programming Principles - Pseudocode Examples
# YAGNI Principle: Over-engineering vs Simplicity
# Bad: Over-engineered cache system with premature flexibility
INTERFACE CacheAdapter
METHOD get(key) -> value
METHOD set(key, value, ttl)
METHOD delete(key)
METHOD clear()
METHOD exists(key) -> boolean
METHOD getMultiple(keys) -> array
METHOD setMultiple(items, ttl)
METHOD increment(key, amount) -> integer
METHOD getTtl(key) -> integer
METHOD addTags(key, tags)
METHOD invalidateByTag(tag) -> count
CLASS RedisCacheAdapter IMPLEMENTS CacheAdapter
// 300+ lines of Redis-specific implementation
// Most methods never used in production
CLASS FileCacheAdapter IMPLEMENTS CacheAdapter
// Complex file locking, serialization, cleanup
CLASS DatabaseCacheAdapter IMPLEMENTS CacheAdapter
// SQL queries for caching - defeats the purpose
CLASS CacheFactory
METHOD create(config) -> CacheAdapter
SWITCH config.type
CASE 'redis': RETURN new RedisCacheAdapter(config)
CASE 'file': RETURN new FileCacheAdapter(config)
// ... more unused implementations
# Good: Simple solution for actual requirement
CLASS SessionStore
PROPERTY redis_client
METHOD get(session_id)
data = redis_client.get("session:" + session_id)
RETURN data ? parse_json(data) : null
METHOD set(session_id, data, ttl = 3600)
redis_client.setex("session:" + session_id, ttl, to_json(data))
METHOD delete(session_id)
redis_client.delete("session:" + session_id)
# Invalid States Prevention: Type Safety vs Runtime Validation
# Bad: Invalid states representable - runtime errors waiting to happen
CLASS UserAccount
PROPERTY email AS string // Any string allowed!
PROPERTY status AS string // "active", "deleted", "banana" - all valid
PROPERTY password_hash AS string // Could be plaintext by accident
PROPERTY verified_at AS datetime // Could be null when shouldn't be
METHOD can_login() -> boolean
// Must defensively check all possible invalid combinations
IF status NOT IN ['active', 'verified', 'pending']
RETURN false
IF password_hash IS null OR password_hash IS empty
RETURN false
IF status = 'pending' AND verified_at IS null
RETURN false
RETURN true
# Problems with above approach:
user = new UserAccount()
user.email = "definitely-not-an-email" // Compiles fine!
user.status = "INVALID_STATUS" // Runtime bug waiting to happen
user.password_hash = "plaintext-password" // Security vulnerability
# Good: Invalid states unrepresentable through type design
ENUM UserStatus
ACTIVE
PENDING_VERIFICATION
SUSPENDED
DELETED
TYPE Email = string WITH CONSTRAINT valid_email_format(value)
TYPE PasswordHash = string WITH CONSTRAINT valid_hash_format(value)
TYPE UserId = string WITH CONSTRAINT non_empty(value)
CLASS UserAccount
PROPERTY id AS UserId
PROPERTY email AS Email
PROPERTY status AS UserStatus
PROPERTY password_hash AS PasswordHash OPTIONAL
PROPERTY verified_at AS datetime OPTIONAL
METHOD can_login() -> boolean
// Type system guarantees status is valid
SWITCH status
CASE ACTIVE:
RETURN password_hash IS NOT null
CASE PENDING_VERIFICATION:
RETURN password_hash IS NOT null AND verified_at IS NOT null
CASE SUSPENDED, DELETED:
RETURN false
// Smart constructors enforce invariants
FUNCTION create_user(id_str, email_str, password_str) -> UserAccount
id = validate_user_id(id_str) // Throws if invalid
email = validate_email(email_str) // Throws if invalid
hash = hash_password(password_str) // Always properly hashed
RETURN new UserAccount(id, email, PENDING_VERIFICATION, hash, null)
# Domain Purity: Separating Business Logic from Infrastructure
# Bad: Domain object polluted with infrastructure dependencies
CLASS Order
PROPERTY order_id
PROPERTY customer_id
PROPERTY items
PROPERTY database // Infrastructure dependency!
PROPERTY email_service // Infrastructure dependency!
PROPERTY payment_gateway // Infrastructure dependency!
METHOD process()
// Domain logic mixed with database queries
inventory = database.query("SELECT * FROM inventory WHERE product_id = ?")
FOR EACH item IN items
IF inventory[item.product_id] < item.quantity
THROW InsufficientInventoryError
// Payment processing mixed into domain logic
result = payment_gateway.charge(total, customer_id)
IF NOT result.success
THROW PaymentFailedError
// Email sending in domain object - wrong layer!
email_service.send_confirmation(customer_id, this)
// Direct database mutation from domain object
database.execute("UPDATE orders SET status='completed'")
# Problems: Hard to test, mixed concerns, infrastructure coupling
# Good: Pure domain object with clean boundaries
ENUM OrderStatus
PENDING
PROCESSING
COMPLETED
FAILED
RECORD OrderProcessed
order_id
customer_id
total
processed_at
RECORD OrderFailed
order_id
reason
failed_at
CLASS Order
PROPERTY order_id
PROPERTY customer_id
PROPERTY items
PROPERTY total
PROPERTY status = PENDING
METHOD can_process(inventory_service) -> boolean
// Pure domain logic - infrastructure injected as dependency
FOR EACH item IN items
IF NOT inventory_service.has_sufficient_stock(item.product_id, item.quantity)
RETURN false
RETURN true
METHOD process() -> OrderProcessed
// Pure state transition - no side effects
IF status != PENDING
THROW DomainException("Order must be pending to process")
status = PROCESSING
// Return domain event instead of performing side effects
RETURN new OrderProcessed(order_id, customer_id, total, now())
METHOD fail(reason) -> OrderFailed
status = FAILED
RETURN new OrderFailed(order_id, reason, now())
# Application service orchestrates infrastructure concerns
CLASS ProcessOrderService
PROPERTY order_repository
PROPERTY inventory_service
PROPERTY payment_service
PROPERTY event_dispatcher
METHOD execute(process_command)
order = order_repository.find_by_id(process_command.order_id)
IF NOT order.can_process(inventory_service)
failed_event = order.fail("Insufficient inventory")
order_repository.save(order)
event_dispatcher.publish(failed_event)
RETURN
TRY
payment_service.charge(order.total, order.customer_id)
processed_event = order.process()
order_repository.save(order)
event_dispatcher.publish(processed_event)
CATCH PaymentError as error
failed_event = order.fail("Payment failed: " + error.message)
order_repository.save(order)
event_dispatcher.publish(failed_event)
# Event handlers manage side effects separately
CLASS OrderCompletedHandler
METHOD handle(order_processed_event)
customer = customer_repository.find_by_id(event.customer_id)
email_service.send_confirmation(customer.email, event)
This extended pseudocode reference demonstrates how YAGNI, invalid state prevention, and domain purity work together in any language that supports appropriate abstractions. Use this as a template when implementing these patterns in your preferred programming language.
Practical Implementation Strategies
Start with YAGNI
When beginning a new feature, ask yourself:
- What is the specific requirement I'm solving?
- What is the simplest solution that could work?
- Am I building for hypothetical future needs?
- Will this additional complexity make the code harder to change later?
Design for Invalid State Prevention
Use your type system's strengths:
- PHP 8.4: Leverage enums, readonly classes, property hooks, asymmetric visibility, and union types
- TypeScript: Use union types, branded types, and discriminated unions
- Any language: Create value objects with validation in constructors
Maintain Domain Boundaries
Keep domain objects pure by:
- Injecting dependencies as interfaces, not concrete implementations
- Returning domain events instead of causing side effects
- Using application services for orchestration
- Testing domain logic in complete isolation
Common Pitfalls and Misconceptions
YAGNI Misapplications
YAGNI doesn't mean writing poor code. As Martin Fowler emphasizes, activities that make code more modifiable—like refactoring, clean coding practices, and good architecture—are not YAGNI violations. The principle targets features built for presumptive needs, not code quality improvements.
Type Safety vs. Performance
Some developers worry that type-safe domain modeling hurts performance. In reality, modern PHP 8.4 and TypeScript engines optimize value object creation effectively. PHP 8.4's property hooks are particularly efficient because validation logic is compiled directly into the property access pattern. The performance cost of additional objects is typically negligible compared to the bugs prevented and the development speed gained through better tooling support.
Purity vs. Completeness Trade-off
The DDD trilemma shows you can't have domain model purity, completeness, and performance simultaneously. In most cases, choose purity over completeness. Split complex operations between pure domain logic and application services rather than polluting domain objects with infrastructure concerns.
Measuring Success
These principles should produce measurable improvements:
Code Quality Metrics
- Cyclomatic complexity: Lower complexity in business logic methods
- Test coverage: Higher coverage achievable due to isolated, testable units
- Bug density: Fewer runtime errors related to invalid states
- Code churn: Less frequent changes to core domain logic
Development Velocity
- Onboarding time: New developers understand type-safe, focused code faster
- Feature delivery: Simple solutions ship faster than over-engineered ones
- Debugging time: Type safety prevents many debugging sessions
- Refactoring confidence: Type systems catch breaking changes automatically
Integration with Modern Development Practices
CI/CD and Type Safety
Static type checking fits naturally into continuous integration. Tools like PHPStan for PHP and TypeScript's compiler catch type-related issues before deployment. Combined with automated testing, this creates multiple layers of validation that complement defensive programming principles.
Domain-Driven Design Alignment
These principles align perfectly with DDD practices:
- Bounded contexts naturally enforce domain purity
- Aggregates become easier to model with type-safe value objects
- Domain events work well with pure domain objects
- Ubiquitous language is expressed clearly through typed domain models
Microservices and API Design
In distributed systems, these principles become even more critical:
- API contracts benefit from type-safe request/response models
- Service boundaries are clearer with pure domain objects
- Data validation happens at service boundaries, not throughout the codebase
- Integration testing focuses on behavior rather than implementation details
Real-World Adoption Strategies
Incremental Implementation
You don't need to refactor entire systems at once:
- Start with new features: Apply these principles to all new code
- Focus on pain points: Refactor areas with frequent bugs first
- Create value objects gradually: Replace primitives with domain types over time
- Extract pure functions: Move business logic out of service classes incrementally
Team Education and Buy-in
Cultural adoption is as important as technical implementation:
- Share concrete examples of bugs these principles would have prevented
- Demonstrate the improved developer experience with type-safe APIs
- Measure and communicate improvements in code quality metrics
- Pair program to spread knowledge of defensive patterns
Conclusion: Building Antifragile Code
These three defensive programming principles work synergistically to create what Nassim Taleb calls "antifragile" systems—code that becomes stronger under stress rather than breaking. YAGNI prevents unnecessary complexity that would make systems brittle. Type safety eliminates entire classes of failures. Domain purity creates clear boundaries that limit the blast radius of changes.
The investment in learning and applying these principles pays dividends throughout a system's lifetime. Code becomes not just more reliable, but more enjoyable to work with. Debugging sessions become less frequent. Feature development becomes more predictable. System complexity remains manageable as applications grow.
Start small—apply one principle to one feature—and experience the difference defensive design makes. Your future self (and your teammates) will thank you for building systems that fail less often and change more easily.
Further Reading
Deepen your understanding with these authoritative resources:
- Martin Fowler on YAGNI - The definitive explanation of when and how to apply YAGNI
- Domain Model Purity vs Completeness - Vladimir Khorikov's analysis of the DDD trilemma
- Make Invalid States Unrepresentable - Comprehensive guide to type-driven development
- PHP 8.4 Property Hooks - Official documentation for PHP 8.4's property hooks
- PHP 8.4 Asymmetric Visibility - RFC documentation for asymmetric property visibility
- PHP 8.4 New Features - Complete list of PHP 8.4 improvements for defensive programming
- Microsoft's Introduction to Domain-Driven Design - Foundational concepts for domain purity
- PHPStan - Static analysis tool for PHP type safety
- TypeScript Narrowing - Advanced type safety techniques
- Design Patterns - Structural patterns that support these principles