Object-Oriented Programming isn’t just about classes and objects—it’s a paradigm that shapes how we architect entire systems.
Beyond the Basics: OOP as Architecture
Most developers learn OOP through textbook examples: Dog extends Animal, Car has Engine. But at the architect level, OOP manifests as:
- System boundaries: What should be a microservice vs a class?
- Coupling decisions: Where do we draw abstraction boundaries?
- Team organization: How do we align teams with object models?
- Performance trade-offs: When does OOP overhead matter?
The Architect’s Reality:
- Pure OOP can be over-engineered
- Procedural code sometimes wins
- Functional patterns often complement OOP
- Hybrid approaches usually work best
The Four Pillars: Architectural View
1. Encapsulation
“Hide internal state and require all interaction through methods.”
The Strategic Value
Encapsulation at scale means information hiding and contract stability:
- Services hide implementation details behind APIs
- Modules expose minimal surface area
- Internal changes don’t ripple through systems
Well-Encapsulated Design
✅ Good Encapsulation:
// Encapsulates complex pricing logic
public class Order
{
private readonly List<OrderItem> _items = new();
private readonly DiscountEngine _discountEngine;
private decimal _subtotal;
private decimal _tax;
private decimal _discount;
// Internal state hidden
public decimal Total { get; private set; }
public OrderStatus Status { get; private set; }
public Order(DiscountEngine discountEngine)
{
_discountEngine = discountEngine;
Status = OrderStatus.Draft;
}
// Controlled mutations through methods
public void AddItem(Product product, int quantity)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot modify submitted order");
_items.Add(new OrderItem(product, quantity));
RecalculateTotal(); // Internal state management
}
public void ApplyDiscount(string couponCode)
{
_discount = _discountEngine.Calculate(couponCode, _subtotal);
RecalculateTotal();
}
public void Submit()
{
if (_items.Count == 0)
throw new InvalidOperationException("Cannot submit empty order");
Status = OrderStatus.Submitted;
// Internal state transition logic
}
private void RecalculateTotal()
{
_subtotal = _items.Sum(i => i.Price * i.Quantity);
_tax = _subtotal * 0.08m;
Total = _subtotal + _tax - _discount;
}
}
Benefits:
- Order’s invariants always maintained (can’t submit empty order)
- Total always consistent with items
- Status transitions controlled
- Internal calculation logic can change without affecting clients
Broken Encapsulation
❌ Poor Encapsulation (Anemic Domain Model):
// Data bag - no behavior, no encapsulation
public class Order
{
public List<OrderItem> Items { get; set; }
public decimal Subtotal { get; set; }
public decimal Tax { get; set; }
public decimal Discount { get; set; }
public decimal Total { get; set; }
public OrderStatus Status { get; set; }
}
// All logic lives in services - procedural programming with extra steps
public class OrderService
{
public void AddItem(Order order, Product product, int quantity)
{
order.Items.Add(new OrderItem(product, quantity));
// Manual calculation
order.Subtotal = order.Items.Sum(i => i.Price * i.Quantity);
order.Tax = order.Subtotal * 0.08m;
order.Total = order.Subtotal + order.Tax - order.Discount;
}
public void Submit(Order order)
{
// No validation of state - can submit invalid orders
order.Status = OrderStatus.Submitted;
}
}
Problems:
- No invariant protection (can create invalid orders)
- Logic scattered across multiple services
- Direct property access allows inconsistent state
- Hard to reason about object lifecycle
When to Break Encapsulation
Sometimes getters/setters are fine:
// Simple DTO for data transfer - no business logic
public class OrderDto
{
public int Id { get; set; }
public string CustomerName { get; set; }
public decimal Total { get; set; }
public DateTime OrderDate { get; set; }
}
// Configuration object - pure data
public class AppConfiguration
{
public string DatabaseConnection { get; set; }
public int MaxRetries { get; set; }
public bool EnableCaching { get; set; }
}
When to skip encapsulation:
- DTOs and data transfer objects
- Configuration objects
- Database entities (ORM requirements)
- Simple value objects with no invariants
Encapsulation at Service Level
❌ Poor Service Encapsulation (Leaky Abstractions)
OrderService exposes:
- GET /orders/{id}/items (internal structure leaked)
- GET /orders/{id}/subtotal
- GET /orders/{id}/tax
- GET /orders/{id}/discount
- POST /orders/{id}/recalculate (internal operation exposed)
Clients know too much about internal calculations
✅ Good Service Encapsulation
OrderService exposes:
- GET /orders/{id} (complete order representation)
- POST /orders (create order)
- PUT /orders/{id}/items (modify items - recalculation hidden)
- POST /orders/{id}/submit
Internal calculations are implementation details
2. Abstraction
“Expose only essential characteristics, hide implementation complexity.”
Abstraction Levels
Good architecture has appropriate abstraction layers:
Business Logic Layer
↓ (abstracts)
Domain Model Layer
↓ (abstracts)
Data Access Layer
↓ (abstracts)
Database
Strategic Abstraction
✅ Value-Adding Abstraction:
// Abstract payment processing complexity
public interface IPaymentProcessor
{
Task<PaymentResult> ProcessPayment(PaymentRequest request);
Task<RefundResult> ProcessRefund(string transactionId, decimal amount);
}
// Clients work with high-level concept
public class CheckoutService
{
private readonly IPaymentProcessor _paymentProcessor;
public async Task<CheckoutResult> Checkout(Cart cart, PaymentInfo payment)
{
// Abstraction hides complexity of multiple payment providers
var paymentRequest = new PaymentRequest
{
Amount = cart.Total,
Currency = "USD",
PaymentMethod = payment.Method,
Token = payment.Token
};
var result = await _paymentProcessor.ProcessPayment(paymentRequest);
if (result.Success)
{
return CheckoutResult.Success(result.TransactionId);
}
return CheckoutResult.Failed(result.ErrorMessage);
}
}
// Multiple implementations hidden behind abstraction
public class StripePaymentProcessor : IPaymentProcessor
{
public async Task<PaymentResult> ProcessPayment(PaymentRequest request)
{
// Complex Stripe API calls hidden
var options = new ChargeCreateOptions
{
Amount = (long)(request.Amount * 100),
Currency = request.Currency,
Source = request.Token,
// 20+ more parameters...
};
var charge = await _stripeClient.Charges.CreateAsync(options);
return MapToPaymentResult(charge);
}
}
public class PayPalPaymentProcessor : IPaymentProcessor
{
public async Task<PaymentResult> ProcessPayment(PaymentRequest request)
{
// Completely different PayPal API hidden
var payment = new Payment
{
intent = "sale",
payer = new Payer { payment_method = "paypal" },
// Different structure than Stripe
};
var createdPayment = await _paypalClient.CreatePaymentAsync(payment);
return MapToPaymentResult(createdPayment);
}
}
Why This Works:
- CheckoutService doesn’t know about Stripe vs PayPal differences
- Can add new payment providers without changing checkout
- Each provider handles its own complexity
- Business logic stays clean
Over-Abstraction
❌ Abstraction Layers Without Purpose:
// Layer 1: Repository interface
public interface IUserRepository
{
Task<User> GetById(int id);
}
// Layer 2: Repository implementation
public class UserRepository : IUserRepository
{
public async Task<User> GetById(int id)
{
return await _context.Users.FindAsync(id);
}
}
// Layer 3: Service interface
public interface IUserService
{
Task<User> GetUser(int id);
}
// Layer 4: Service implementation
public class UserService : IUserService
{
private readonly IUserRepository _repository;
public async Task<User> GetUser(int id)
{
// Just passes through - adds no value
return await _repository.GetById(id);
}
}
// Layer 5: Facade interface
public interface IUserFacade
{
Task<User> FetchUser(int id);
}
// Layer 6: Facade implementation
public class UserFacade : IUserFacade
{
private readonly IUserService _service;
public async Task<User> FetchUser(int id)
{
// Still just passing through
return await _service.GetUser(id);
}
}
// Controller finally uses it
public class UserController
{
private readonly IUserFacade _facade;
public async Task<IActionResult> Get(int id)
{
// 6 layers to read from database!
var user = await _facade.FetchUser(id);
return Ok(user);
}
}
Problems:
- Indirection overhead: 6 classes to do simple database read
- Debugging nightmare: Step through 6 layers
- No added value: Each layer just passes data through
- Maintenance burden: Change signature = update 6 places
✅ Right Amount of Abstraction:
// Single repository layer is sufficient
public interface IUserRepository
{
Task<User> GetById(int id);
Task<User> GetByEmail(string email);
Task<IEnumerable<User>> GetAll();
Task Add(User user);
Task Update(User user);
}
public class UserController
{
private readonly IUserRepository _userRepository;
public async Task<IActionResult> Get(int id)
{
var user = await _userRepository.GetById(id);
return user != null ? Ok(user) : NotFound();
}
}
Abstraction Decision Framework
High Complexity + Multiple Implementations = Abstract
├── Payment gateways: ✅ Abstract
├── Cloud storage providers: ✅ Abstract
├── Authentication providers: ✅ Abstract
└── Message queues: ✅ Abstract
Low Complexity + Single Implementation = Don't Abstract
├── Simple CRUD operations: ❌ Keep direct
├── Internal utilities: ❌ Keep direct
├── Stable domain logic: ❌ Keep direct
└── One-time integrations: ❌ Keep direct
3. Inheritance
“Create class hierarchies where subclasses inherit behavior from parent classes.”
The Inheritance Trap
Inheritance is the most overused and misunderstood OOP principle. At the architect level:
- Composition usually beats inheritance
- Deep hierarchies are maintenance nightmares
- Inheritance creates tight coupling
When Inheritance Makes Sense
✅ Appropriate Use (Behavioral Extension):
// Framework base class with Template Method pattern
public abstract class BackgroundService
{
private CancellationTokenSource _stoppingCts;
// Template method - defines algorithm structure
public async Task StartAsync(CancellationToken cancellationToken)
{
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
await OnStarting(cancellationToken);
await ExecuteAsync(_stoppingCts.Token);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_stoppingCts.Cancel();
await OnStopping(cancellationToken);
}
// Subclasses override this to provide custom behavior
protected abstract Task ExecuteAsync(CancellationToken stoppingToken);
protected virtual Task OnStarting(CancellationToken cancellationToken)
=> Task.CompletedTask;
protected virtual Task OnStopping(CancellationToken cancellationToken)
=> Task.CompletedTask;
}
// Concrete implementations
public class OrderProcessorService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await ProcessPendingOrders();
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}
}
public class EmailSenderService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await SendQueuedEmails();
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}
Why This Works:
- Common lifecycle management in base class
- Specific behavior in subclasses
- Framework handles infrastructure concerns
- Clean separation of concerns
When Inheritance Creates Problems
❌ Inheritance for Code Reuse:
// Using inheritance just to share code
public class Employee
{
public string Name { get; set; }
public string Email { get; set; }
public string PhoneNumber { get; set; }
public void SendEmail(string message)
{
// Email sending logic
}
public void SendSMS(string message)
{
// SMS sending logic
}
}
// Customer inherits from Employee to get communication methods
public class Customer : Employee
{
public decimal AccountBalance { get; set; }
public List<Order> Orders { get; set; }
}
// Now Customer "IS-A" Employee - semantically wrong!
Problems:
- Violated IS-A relationship: Customer is NOT an Employee
- Tight coupling: Changes to Employee affect Customer
- Brittle hierarchy: Can’t change inheritance later without breaking everything
- Misleading domain model: Doesn’t represent real business concepts
✅ Use Composition Instead:
// Shared behavior extracted to separate service
public interface INotificationService
{
Task SendEmail(string recipient, string message);
Task SendSMS(string phoneNumber, string message);
}
// Employee uses notification service
public class Employee
{
private readonly INotificationService _notificationService;
public string Name { get; set; }
public string Email { get; set; }
public string PhoneNumber { get; set; }
public Employee(INotificationService notificationService)
{
_notificationService = notificationService;
}
public async Task NotifyByEmail(string message)
{
await _notificationService.SendEmail(Email, message);
}
}
// Customer also uses notification service
public class Customer
{
private readonly INotificationService _notificationService;
public string Name { get; set; }
public string Email { get; set; }
public decimal AccountBalance { get; set; }
public Customer(INotificationService notificationService)
{
_notificationService = notificationService;
}
public async Task NotifyByEmail(string message)
{
await _notificationService.SendEmail(Email, message);
}
}
Deep Inheritance Hierarchies
❌ Inheritance Hell:
// 7 levels deep - maintenance nightmare
public class Entity { }
public class DomainEntity : Entity { }
public class AuditableEntity : DomainEntity { }
public class Person : AuditableEntity { }
public class Employee : Person { }
public class Manager : Employee { }
public class SeniorManager : Manager { }
// To understand SeniorManager, you need to understand 6 parent classes
// Changes ripple through entire hierarchy
// Fragile Base Class Problem: changing Entity can break everything
✅ Shallow Hierarchy:
// Maximum 2-3 levels
public abstract class Entity
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
}
public class Employee : Entity
{
public string Name { get; set; }
public string Department { get; set; }
public EmployeeType Type { get; set; } // Use enum or value object
}
// Use composition for specialized behavior
public class ManagerCapabilities
{
public List<Employee> DirectReports { get; set; }
public decimal ApprovalLimit { get; set; }
}
public class SeniorManagerCapabilities
{
public List<Manager> Managers { get; set; }
public List<string> Departments { get; set; }
}
Inheritance vs Composition Decision Tree
Do subclasses share behavior AND satisfy "IS-A" relationship?
├── Yes → Consider inheritance
│ └── Is hierarchy shallow (max 2-3 levels)?
│ ├── Yes → Inheritance OK
│ └── No → Use composition
└── No → Use composition
Examples:
├── Button IS-A Control: ✅ Inheritance
├── Rectangle IS-A Shape: ✅ Inheritance
├── Manager IS-A Employee: ⚠️ Use composition (specialization)
└── Car HAS-A Engine: ✅ Composition
4. Polymorphism
“Use a single interface to represent different underlying forms.”
Strategic Polymorphism
Polymorphism enables plugin architectures and strategy patterns at scale.
✅ Polymorphic Plugin System:
// Define behavior contract
public interface IOrderProcessor
{
string ProcessorName { get; }
bool CanProcess(Order order);
Task<ProcessingResult> Process(Order order);
}
// Multiple implementations
public class StandardOrderProcessor : IOrderProcessor
{
public string ProcessorName => "Standard";
public bool CanProcess(Order order)
=> order.Total < 1000 && order.Items.Count < 10;
public async Task<ProcessingResult> Process(Order order)
{
// Standard processing logic
await ValidateInventory(order);
await ChargePayment(order);
return ProcessingResult.Success();
}
}
public class BulkOrderProcessor : IOrderProcessor
{
public string ProcessorName => "Bulk";
public bool CanProcess(Order order)
=> order.Items.Count >= 10;
public async Task<ProcessingResult> Process(Order order)
{
// Bulk processing with different logic
await ValidateInventoryBulk(order);
await ApplyBulkDiscount(order);
await ChargePayment(order);
return ProcessingResult.Success();
}
}
public class HighValueOrderProcessor : IOrderProcessor
{
public string ProcessorName => "High-Value";
public bool CanProcess(Order order)
=> order.Total >= 1000;
public async Task<ProcessingResult> Process(Order order)
{
// Additional verification for high-value orders
await VerifyCustomerCredit(order);
await FraudCheck(order);
await ValidateInventory(order);
await ChargePayment(order);
await NotifyAccountManager(order);
return ProcessingResult.Success();
}
}
// Processor orchestrator uses polymorphism
public class OrderProcessingService
{
private readonly IEnumerable<IOrderProcessor> _processors;
public OrderProcessingService(IEnumerable<IOrderProcessor> processors)
{
// DI automatically discovers all implementations
_processors = processors;
}
public async Task<ProcessingResult> ProcessOrder(Order order)
{
// Select appropriate processor polymorphically
var processor = _processors.FirstOrDefault(p => p.CanProcess(order));
if (processor == null)
throw new InvalidOperationException("No processor available for order");
return await processor.Process(order);
}
}
Architectural Benefits:
- Add new processors without modifying orchestrator
- Each processor encapsulates its own rules
- Easy to test each processor independently
- Clear separation of concerns
Polymorphism in Distributed Systems
✅ Service-Level Polymorphism:
// Abstract notification behavior
public interface INotificationProvider
{
string ProviderName { get; }
Task<SendResult> Send(NotificationRequest request);
}
// Email provider
public class EmailNotificationProvider : INotificationProvider
{
public string ProviderName => "Email";
public async Task<SendResult> Send(NotificationRequest request)
{
// SMTP logic
}
}
// SMS provider
public class SmsNotificationProvider : INotificationProvider
{
public string ProviderName => "SMS";
public async Task<SendResult> Send(NotificationRequest request)
{
// Twilio API logic
}
}
// Push notification provider
public class PushNotificationProvider : INotificationProvider
{
public string ProviderName => "Push";
public async Task<SendResult> Send(NotificationRequest request)
{
// Firebase Cloud Messaging logic
}
}
// Slack provider
public class SlackNotificationProvider : INotificationProvider
{
public string ProviderName => "Slack";
public async Task<SendResult> Send(NotificationRequest request)
{
// Slack webhook logic
}
}
// Notification service uses polymorphism for multi-channel delivery
public class NotificationService
{
private readonly Dictionary<NotificationChannel, INotificationProvider> _providers;
public NotificationService(IEnumerable<INotificationProvider> providers)
{
_providers = providers.ToDictionary(
p => Enum.Parse<NotificationChannel>(p.ProviderName),
p => p);
}
public async Task<NotificationResult> SendNotification(
NotificationRequest request,
NotificationChannel[] channels)
{
var tasks = channels
.Select(channel => _providers[channel].Send(request))
.ToArray();
var results = await Task.WhenAll(tasks);
return new NotificationResult
{
SuccessCount = results.Count(r => r.Success),
FailureCount = results.Count(r => !r.Success)
};
}
}
When Polymorphism Adds Complexity
❌ Over-Engineering Simple Logic:
// Polymorphism for formatting - overkill
public interface IUserNameFormatter
{
string Format(User user);
}
public class FirstLastFormatter : IUserNameFormatter
{
public string Format(User user) => $"{user.FirstName} {user.LastName}";
}
public class LastFirstFormatter : IUserNameFormatter
{
public string Format(User user) => $"{user.LastName}, {user.FirstName}";
}
public class InitialsFormatter : IUserNameFormatter
{
public string Format(User user) => $"{user.FirstName[0]}{user.LastName[0]}";
}
// 3 classes for simple string formatting
✅ Simple Methods Instead:
public static class UserNameFormatter
{
public static string FirstLast(User user)
=> $"{user.FirstName} {user.LastName}";
public static string LastFirst(User user)
=> $"{user.LastName}, {user.FirstName}";
public static string Initials(User user)
=> $"{user.FirstName[0]}{user.LastName[0]}";
}
// Use directly where needed
var display = UserNameFormatter.FirstLast(user);
OOP Anti-Patterns at Scale
1. God Objects
// ❌ God Object - knows and does everything
public class OrderManager
{
// Properties - too many responsibilities
public DbContext Database { get; set; }
public IEmailService EmailService { get; set; }
public IPaymentGateway PaymentGateway { get; set; }
public IInventoryService InventoryService { get; set; }
public IShippingService ShippingService { get; set; }
public INotificationService NotificationService { get; set; }
public ILogger Logger { get; set; }
public IConfiguration Configuration { get; set; }
// 50+ methods handling everything related to orders
public async Task CreateOrder() { }
public async Task UpdateOrder() { }
public async Task CancelOrder() { }
public async Task ProcessPayment() { }
public async Task RefundPayment() { }
public async Task ValidateInventory() { }
public async Task ReserveInventory() { }
public async Task ReleaseInventory() { }
public async Task CalculateShipping() { }
public async Task GenerateInvoice() { }
public async Task SendConfirmationEmail() { }
public async Task SendShippingNotification() { }
public async Task GeneratePackingSlip() { }
public async Task UpdateOrderStatus() { }
public async Task ApplyDiscount() { }
public async Task CalculateTax() { }
// ... 35 more methods
}
Solution: Break into cohesive classes with single responsibilities.
2. Anemic Domain Model
// ❌ All data, no behavior
public class Order
{
public int Id { get; set; }
public List<OrderItem> Items { get; set; }
public OrderStatus Status { get; set; }
public decimal Total { get; set; }
}
// All logic in services - just procedural programming
public class OrderService
{
public void Calculate(Order order) { }
public void Validate(Order order) { }
public void Submit(Order order) { }
}
Solution: Put behavior where data lives (rich domain model).
3. Yo-Yo Problem
// ❌ Method calls bounce up and down inheritance hierarchy
public class GrandParent
{
public virtual void DoSomething()
{
Step1();
Step2();
}
protected virtual void Step1() { }
protected virtual void Step2() { }
}
public class Parent : GrandParent
{
protected override void Step1()
{
base.Step1(); // Call grandparent
// Parent logic
}
}
public class Child : Parent
{
public override void DoSomething()
{
base.DoSomething(); // Call parent
Step3();
}
protected override void Step2()
{
base.Step2(); // Call parent
// Child logic
}
private void Step3() { }
}
// To understand Child.DoSomething(), you trace through 3 classes
Solution: Favor composition or keep hierarchies flat.
4. Circle-Ellipse Problem
// ❌ Violates Liskov Substitution Principle
public class Ellipse
{
public virtual double MajorAxis { get; set; }
public virtual double MinorAxis { get; set; }
public double Area => Math.PI * MajorAxis * MinorAxis;
}
public class Circle : Ellipse
{
private double _radius;
public double Radius
{
get => _radius;
set
{
_radius = value;
base.MajorAxis = value;
base.MinorAxis = value;
}
}
public override double MajorAxis
{
get => _radius;
set => Radius = value;
}
public override double MinorAxis
{
get => _radius;
set => Radius = value;
}
}
// Breaks when you do:
void ResizeEllipse(Ellipse ellipse)
{
ellipse.MajorAxis = 10;
ellipse.MinorAxis = 5;
// If ellipse is Circle, this creates inconsistent state
}
Solution: Don’t use inheritance when mathematical IS-A relationship doesn’t hold.
OOP in Modern Architectures
Microservices and OOP
Traditional OOP Hierarchy:
└── PaymentService
├── CreditCardPayment
├── BankTransferPayment
└── CryptoPayment
Microservices Approach:
├── CreditCardPaymentService (independent service)
├── BankTransferPaymentService (independent service)
└── CryptoPaymentService (independent service)
OOP principles apply WITHIN each service, not ACROSS services
Event-Driven Systems
// OOP within bounded context
public class Order
{
private readonly List<IDomainEvent> _events = new();
public void Submit()
{
Status = OrderStatus.Submitted;
_events.Add(new OrderSubmittedEvent(Id, CustomerId, Total));
}
public IReadOnlyList<IDomainEvent> GetEvents() => _events;
}
// Polymorphic event handlers
public interface IEventHandler<TEvent> where TEvent : IDomainEvent
{
Task Handle(TEvent @event);
}
public class OrderSubmittedEmailHandler : IEventHandler<OrderSubmittedEvent>
{
public async Task Handle(OrderSubmittedEvent @event)
{
await SendConfirmationEmail(@event);
}
}
public class OrderSubmittedInventoryHandler : IEventHandler<OrderSubmittedEvent>
{
public async Task Handle(OrderSubmittedEvent @event)
{
await ReserveInventory(@event);
}
}
The Pragmatic OOP Decision Matrix
| Principle | Apply When | Skip When |
|---|---|---|
| Encapsulation | Complex invariants, state management | Simple DTOs, configuration objects |
| Abstraction | Multiple implementations, high variance | Single implementation, stable code |
| Inheritance | Framework base classes, IS-A relationships | Code reuse only, deep hierarchies |
| Polymorphism | Plugin architectures, strategy patterns | Simple conditional logic, formatting |
Key Takeaways
-
OOP is a tool
- Use it where it adds value
- Mix with functional and procedural approaches
- Optimize for readability and maintainability
-
Favor composition over inheritance
- Inheritance creates tight coupling
- Composition provides flexibility
- Keep hierarchies shallow (max 2-3 levels)
-
Encapsulate what changes
- Hide complexity behind stable interfaces
- Protect invariants through controlled mutations
- Skip encapsulation for simple data transfer
-
Abstract strategically
- Add abstraction layers when there’s variance
- Don’t abstract stable, simple code
- Each layer must add value
-
Polymorphism enables extensibility
- Use for plugin architectures
- Good for strategy patterns
- Overkill for simple conditional logic
-
Context matters more than principles
- Small teams → Less abstraction
- Large teams → Clear boundaries
- Microservices → Bounded contexts
- Monoliths → Rich domain models