Modern Best Practices

Object-Oriented Programming

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