Modern Best Practices
Composition Over Inheritance
Definition: Favor object composition over class inheritance to achieve code reuse and flexibility.
Why This Matters:
- Inheritance creates tight coupling between parent and child classes
- Deep inheritance hierarchies become fragile and hard to maintain
- Composition provides better flexibility and testability
- Avoids the “fragile base class” problem
Inheritance Problems:
// BAD: Rigid inheritance hierarchy
public class Animal
{
public virtual void Move() => Console.WriteLine("Moving");
public virtual void MakeSound() => Console.WriteLine("Some sound");
}
public class Dog : Animal
{
public override void MakeSound() => Console.WriteLine("Bark!");
}
public class RobotDog : Dog // Robot dog inherits ALL dog behavior
{
// Problem: Inherits MakeSound, but robots don't breathe
// Stuck with unwanted Animal behaviors
}
Composition Solution:
// GOOD: Flexible composition
public interface IMovable
{
void Move();
}
public interface ISoundMaker
{
void MakeSound();
}
public class WalkingMovement : IMovable
{
public void Move() => Console.WriteLine("Walking on legs");
}
public class WheelMovement : IMovable
{
public void Move() => Console.WriteLine("Rolling on wheels");
}
public class BarkSound : ISoundMaker
{
public void MakeSound() => Console.WriteLine("Bark!");
}
public class RobotSound : ISoundMaker
{
public void MakeSound() => Console.WriteLine("Beep boop!");
}
// Compose behaviors as needed
public class Dog
{
private readonly IMovable movement;
private readonly ISoundMaker soundMaker;
public Dog(IMovable movement, ISoundMaker soundMaker)
{
this.movement = movement;
this.soundMaker = soundMaker;
}
public void Move() => movement.Move();
public void MakeSound() => soundMaker.MakeSound();
}
// Easy to create different combinations
var regularDog = new Dog(new WalkingMovement(), new BarkSound());
var robotDog = new Dog(new WheelMovement(), new RobotSound());
When to Use:
- Inheritance: True “is-a” relationships with shallow hierarchies (1-2 levels)
- Composition: “has-a” or “uses-a” relationships, behavior combinations
Dependency Injection
Definition: Provide dependencies from outside rather than creating them internally, enabling loose coupling and testability.
Constructor Injection
Purpose: Inject required dependencies that the class cannot function without.
public class OrderService
{
private readonly IOrderRepository orderRepository;
private readonly IPaymentProcessor paymentProcessor;
private readonly IEmailService emailService;
// Dependencies required for class to function
public OrderService(
IOrderRepository orderRepository,
IPaymentProcessor paymentProcessor,
IEmailService emailService)
{
this.orderRepository = orderRepository ?? throw new ArgumentNullException(nameof(orderRepository));
this.paymentProcessor = paymentProcessor ?? throw new ArgumentNullException(nameof(paymentProcessor));
this.emailService = emailService ?? throw new ArgumentNullException(nameof(emailService));
}
public async Task<Result> ProcessOrder(Order order)
{
await orderRepository.Save(order);
var paymentResult = await paymentProcessor.Process(order.Total);
if (paymentResult.Success)
{
await emailService.SendConfirmation(order.CustomerEmail);
}
return paymentResult;
}
}
Property Injection
Purpose: Inject optional dependencies or provide defaults.
public class ReportGenerator
{
private ILogger logger;
// Optional dependency with default
public ILogger Logger
{
get => logger ?? NullLogger.Instance;
set => logger = value;
}
public string GenerateReport(ReportData data)
{
Logger.Log("Starting report generation");
var report = CreateReport(data);
Logger.Log("Report generation completed");
return report;
}
}
Method Injection
Purpose: Inject dependencies needed only for specific operations.
public class DocumentProcessor
{
public void Process(Document document, IValidator validator, IFormatter formatter)
{
// Dependencies specific to this operation
if (validator.Validate(document))
{
var formatted = formatter.Format(document);
Save(formatted);
}
}
}
Dependency Injection Benefits
// Testability Example
public class OrderServiceTests
{
[Fact]
public async Task ProcessOrder_ShouldSendEmail_WhenPaymentSucceeds()
{
// Arrange - Easy to inject test doubles
var mockRepository = new Mock<IOrderRepository>();
var mockPaymentProcessor = new Mock<IPaymentProcessor>();
mockPaymentProcessor.Setup(p => p.Process(It.IsAny<decimal>()))
.ReturnsAsync(new Result { Success = true });
var mockEmailService = new Mock<IEmailService>();
var service = new OrderService(
mockRepository.Object,
mockPaymentProcessor.Object,
mockEmailService.Object);
var order = new Order { Total = 100m, CustomerEmail = "test@example.com" };
// Act
await service.ProcessOrder(order);
// Assert - Verify email was sent
mockEmailService.Verify(e =>
e.SendConfirmation("test@example.com"), Times.Once);
}
}
Clean Code Principles
KISS (Keep It Simple, Stupid)
Definition: Choose simple solutions over complex ones. Avoid over-engineering.
// BAD: Over-engineered
public class ComplexCalculator
{
private readonly ICalculationStrategy strategy;
private readonly ICalculationFactory factory;
private readonly ICalculationValidator validator;
public int Add(int a, int b)
{
var operation = factory.CreateOperation(OperationType.Addition);
var context = new CalculationContext(a, b);
var validation = validator.Validate(context);
if (!validation.IsValid)
throw new InvalidOperationException();
return strategy.Execute(operation, context);
}
}
// GOOD: Simple and clear
public class SimpleCalculator
{
public int Add(int a, int b) => a + b;
}
When Simple is Better:
- Straightforward business logic
- One-time use code
- Internal utilities
- Prototypes and MVPs
When Complexity is Justified:
- Anticipating multiple variations
- Complex business rules
- Framework/library code
- High reuse scenarios
YAGNI (You Aren’t Gonna Need It)
Definition: Don’t implement features before they’re needed. Focus on current requirements.
// BAD: Speculative generality
public class UserService
{
// Implementing features "just in case"
public Task<User> GetUser(int id) { /* ... */ }
public Task<User> GetUserByEmail(string email) { /* Not needed yet */ }
public Task<User> GetUserByPhone(string phone) { /* Not needed yet */ }
public Task<User> GetUserByExternalId(string id) { /* Not needed yet */ }
public Task<List<User>> SearchUsers(UserSearchCriteria criteria) { /* Not needed yet */ }
}
// GOOD: Implement only what's needed now
public class UserService
{
// Only implement what current requirements demand
public Task<User> GetUser(int id) { /* ... */ }
// Add other methods when actually needed
}
Benefits:
- Less code to maintain
- Faster initial development
- Easier to understand
- Avoid wrong assumptions about future needs
DRY (Don’t Repeat Yourself)
Definition: Eliminate code duplication through abstraction. Each piece of knowledge should have a single, authoritative representation.
// BAD: Repetitive code
public class OrderController
{
public IActionResult CreateOrder(CreateOrderRequest request)
{
if (string.IsNullOrEmpty(request.CustomerName))
return BadRequest("Customer name is required");
if (request.Total <= 0)
return BadRequest("Total must be positive");
if (string.IsNullOrEmpty(request.Email))
return BadRequest("Email is required");
// Process order
}
public IActionResult UpdateOrder(UpdateOrderRequest request)
{
if (string.IsNullOrEmpty(request.CustomerName))
return BadRequest("Customer name is required");
if (request.Total <= 0)
return BadRequest("Total must be positive");
if (string.IsNullOrEmpty(request.Email))
return BadRequest("Email is required");
// Update order
}
}
// GOOD: Extract common logic
public class OrderController
{
private IActionResult ValidateOrder(string customerName, decimal total, string email)
{
if (string.IsNullOrEmpty(customerName))
return BadRequest("Customer name is required");
if (total <= 0)
return BadRequest("Total must be positive");
if (string.IsNullOrEmpty(email))
return BadRequest("Email is required");
return null; // Valid
}
public IActionResult CreateOrder(CreateOrderRequest request)
{
var validationError = ValidateOrder(request.CustomerName, request.Total, request.Email);
if (validationError != null)
return validationError;
// Process order
}
public IActionResult UpdateOrder(UpdateOrderRequest request)
{
var validationError = ValidateOrder(request.CustomerName, request.Total, request.Email);
if (validationError != null)
return validationError;
// Update order
}
}
Balance DRY with SRP:
// BETTER: Separate validation responsibility
public class OrderValidator
{
public ValidationResult Validate(OrderData data)
{
var errors = new List<string>();
if (string.IsNullOrEmpty(data.CustomerName))
errors.Add("Customer name is required");
if (data.Total <= 0)
errors.Add("Total must be positive");
if (string.IsNullOrEmpty(data.Email))
errors.Add("Email is required");
return new ValidationResult(errors);
}
}
public class OrderController
{
private readonly OrderValidator validator;
public OrderController(OrderValidator validator)
{
this.validator = validator;
}
public IActionResult CreateOrder(CreateOrderRequest request)
{
var validation = validator.Validate(request);
if (!validation.IsValid)
return BadRequest(validation.Errors);
// Process order
}
}
Testing Considerations
Design for Testability
SOLID Principles Improve Testing:
// Hard to test - tight coupling
public class OrderProcessor
{
public void Process(Order order)
{
var repository = new OrderRepository(); // Cannot mock
var emailService = new SmtpEmailService(); // Cannot test without SMTP
repository.Save(order);
emailService.Send(order.CustomerEmail, "Order received");
}
}
// Easy to test - dependency injection
public class OrderProcessor
{
private readonly IOrderRepository repository;
private readonly IEmailService emailService;
public OrderProcessor(IOrderRepository repository, IEmailService emailService)
{
this.repository = repository;
this.emailService = emailService;
}
public void Process(Order order)
{
repository.Save(order);
emailService.Send(order.CustomerEmail, "Order received");
}
}
Testable Code Characteristics
1. Single Responsibility: Easy to test one thing at a time
// One test per class responsibility
[Fact]
public void CalculateTotalPrice_ShouldSumItemPrices()
{
var calculator = new PriceCalculator();
var items = new[] { new Item { Price = 10 }, new Item { Price = 20 } };
var total = calculator.CalculateTotalPrice(items);
Assert.Equal(30, total);
}
2. Dependency Injection: Mock external dependencies
[Fact]
public void ProcessOrder_ShouldCallRepository()
{
var mockRepo = new Mock<IOrderRepository>();
var service = new OrderService(mockRepo.Object);
service.ProcessOrder(new Order());
mockRepo.Verify(r => r.Save(It.IsAny<Order>()), Times.Once);
}
3. No Hidden Dependencies: All dependencies explicit
// BAD: Hidden dependency on DateTime.Now
public class OrderService
{
public void CreateOrder(Order order)
{
order.CreatedAt = DateTime.Now; // Hard to test specific times
}
}
// GOOD: Inject time provider
public class OrderService
{
private readonly ITimeProvider timeProvider;
public OrderService(ITimeProvider timeProvider)
{
this.timeProvider = timeProvider;
}
public void CreateOrder(Order order)
{
order.CreatedAt = timeProvider.Now; // Easy to test with mock
}
}
Quick Reference
Best Practice Decision Matrix
Scenario | Best Practice | Why |
---|---|---|
Need behavior reuse | Composition | More flexible than inheritance |
Need different implementations | Dependency Injection | Enables loose coupling |
Simple calculation | KISS | Avoid unnecessary complexity |
Feature not yet needed | YAGNI | Don’t build what you don’t need |
Repeated validation logic | DRY | Single source of truth |
Testing complex logic | SOLID + DI | Enables mocking and isolation |
Common Pitfalls
Anti-Pattern | Problem | Solution |
---|---|---|
God Object | Class does too much | Apply SRP, split responsibilities |
Premature Optimization | Complex code “for performance” | KISS - optimize when needed |
Shotgun Surgery | Changes require editing many files | DRY - centralize logic |
Tight Coupling | Hard to test and change | DI - depend on abstractions |
Gold Plating | Over-engineering features | YAGNI - build what’s needed |
Modern Framework Integration
ASP.NET Core:
- Built-in dependency injection container
- Middleware pattern (composition)
- Options pattern (configuration)
- Minimal APIs (KISS)
Entity Framework:
- Repository pattern (abstraction)
- Unit of Work pattern (transaction management)
- Lazy loading (proxy pattern)
- DbContext lifetime management (DI)
Testing Tools:
- xUnit / NUnit / MSTest (test frameworks)
- Moq / NSubstitute (mocking)
- FluentAssertions (readable assertions)
- AutoFixture (test data generation)
Found this guide helpful? Share it with your team:
Share on LinkedIn