01
Design Patterns
I find that using tried and tested patterns and structures result in clean, flexible, readable, and maintainable code, even as projects scale.
Pattern
Observer / Event System
Decouple components so they communicate without knowing about each other.
TightlyCoupled.cs✗ Before
public class Player { public void TakeDamage(float amount) { Health -= amount; // Every system hardcoded here... _ui.UpdateHealthBar(); _sound.Play("hit"); _camera.Shake(0.3f); _analytics.Log("damage"); if (Health <= 0) { _ui.ShowDeathScreen(); _sound.Play("death"); _leaderboard.Submit(); } } }
EventDriven.cs✓ After
public static class EventBus { static Dictionary<string, List<Action<object>>> _listeners = new(); public static void On(string evt, Action<object> cb) => _listeners.GetOrAdd(evt).Add(cb); public static void Emit(string evt, object data = null) { if (_listeners.TryGetValue(evt, out var cbs)) cbs.ForEach(cb => cb(data)); } } public class Player { public void TakeDamage(float amount) { Health -= amount; EventBus.Emit("player.hit", amount); if (Health <= 0) EventBus.Emit("player.died"); } } // Systems subscribe independently EventBus.On("player.hit", _ => ui.UpdateHealthBar()); EventBus.On("player.hit", _ => camera.Shake()); EventBus.On("player.died", _ => leaderboard.Submit());
Why it matters: Adding a new reaction to damage is one line. Removing one
doesn't break anything else. Player doesn't know or care what's listening.
Pattern
Strategy Pattern
Swap algorithms at runtime without changing the code that uses them.
Hardcoded.cs✗ Before
public decimal CalculatePrice(Order order, string type) { if (type == "percent") return order.Total * 0.9m; else if (type == "bogo") return order.Total - order.CheapestItem; else if (type == "flat") return order.Total - 10m; else if (type == "loyalty") return order.Total * (1 - order.User.Tier * 0.05m); // Grows forever... return order.Total; }
Strategy.cs✓ After
public interface IDiscount { decimal Apply(Order order); } public class PercentOff : IDiscount { readonly decimal _pct; public PercentOff(decimal pct) => _pct = pct; public decimal Apply(Order order) => order.Total * (1 - _pct); } public class BuyOneGetOne : IDiscount { public decimal Apply(Order order) => order.Total - order.CheapestItem; } // Swap strategy without touching logic order.Discount = new PercentOff(0.1m); var finalPrice = order.Discount.Apply(order);
Why it matters: New discount types are new classes — no existing code
changes. Each strategy is independently testable.
Pattern
State Machine
Replace if's but's and maybe's with predictable state transitions.
FlagHell.cs✗ Before
void Update() { if (isAlerted && !isDead && !isStunned) { if (canSeePlayer && !isReloading) { if (distToPlayer < 5f) { // melee? shoot? flee? if (health < 20 && !isFleeing) { isFleeing = true; isAlerted = false; } } } } // 200 more lines of this }
StateMachine.cs✓ After
public abstract class AIState { public virtual void Enter() { } public abstract AIState Update(); public virtual void Exit() { } } public class PatrolState : AIState { public override AIState Update() { _agent.Patrol(); if (_agent.CanSeePlayer) return new ChaseState(_agent); return this; } } public class ChaseState : AIState { public override AIState Update() { _agent.MoveToward(_player); if (_agent.Health < 20) return new FleeState(_agent); if (_agent.InAttackRange) return new AttackState(_agent); return this; } }
Why it matters: Each state is self-contained. Transitions are explicit.
Adding a new behaviour is a new class, not another nested
if.02
Error Handling
Catching problems early, failing gracefully, and giving callers useful information when things go wrong.
Error Handling
Custom Exception Types
Domain-specific errors tell callers exactly what went wrong — not just "something broke".
VagueErrors.cs✗ Before
public void Withdraw(Account acc, decimal amount) { if (amount <= 0) throw new Exception("Bad amount"); if (amount > acc.Balance) throw new Exception("Can't do that"); if (acc.IsFrozen) throw new Exception("Nope"); } // Caller has to parse strings... try { Withdraw(acc, 500); } catch (Exception e) { if (e.Message.Contains("Bad")) // fragile ... }
TypedErrors.cs✓ After
public class InvalidAmountException : ArgumentException { public InvalidAmountException(decimal amount) : base($"Amount must be positive, got {amount}") { } } public class InsufficientFundsException : Exception { public decimal Shortfall { get; } public InsufficientFundsException(decimal bal, decimal req) : base($"Need £{req}, have £{bal}") => Shortfall = req - bal; } public class AccountFrozenException : Exception { } // Caller handles each case precisely try { Withdraw(acc, 500); } catch (InsufficientFundsException e) { SuggestLowerAmount(e.Shortfall); } catch (AccountFrozenException) { ShowContactSupport(); }
Why it matters: Each error carries context. Callers handle specific
failures with specific recovery — no string parsing, no guessing.
Error Handling
Null Checks + Safe Defaults
Handle missing data explicitly and keep the app stable with intentional fallback values.
NullRefRisk.cs✗ Before
public string BuildWelcomeMessage(User user) { // Crashes if any nested property is null var first = user.Profile.FirstName.Trim(); var plan = user.Subscription.PlanName.ToUpper(); return $"Welcome {first} • {plan}"; }
SafeFallbacks.cs✓ After
public string BuildWelcomeMessage(User? user) { if (user is null) return "Welcome Guest • FREE"; var first = string.IsNullOrWhiteSpace(user.Profile?.FirstName) ? "Guest" : user.Profile.FirstName.Trim(); var plan = string.IsNullOrWhiteSpace(user.Subscription?.PlanName) ? "FREE" : user.Subscription.PlanName.ToUpperInvariant(); return $"Welcome {first} • {plan}"; }
Why it matters: No null-reference crashes, predictable output for
incomplete records, and clear defaults that make failures visible without breaking UX.
Error Handling
Async Timeouts + Cancellation Tokens
Stop slow dependencies from hanging requests and return controlled errors when calls time out
or get canceled.
NoTimeouts.cs✗ Before
public async Task<Quote> GetQuote(string symbol) { // If provider hangs, this request hangs too var res = await _http.GetAsync( $"/quotes/{symbol}"); res.EnsureSuccessStatusCode(); return await res.Content.ReadFromJsonAsync<Quote>(); }
TimeoutSafe.cs✓ After
public async Task<QuoteResult> GetQuote( string symbol, CancellationToken requestCt) { using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( requestCt, timeoutCts.Token); try { var res = await _http.GetAsync( $"/quotes/{symbol}", linkedCts.Token); if (!res.IsSuccessStatusCode) return QuoteResult.Fail("provider_error"); var quote = await res.Content.ReadFromJsonAsync<Quote>( cancellationToken: linkedCts.Token); return quote is null ? QuoteResult.Fail("empty_payload") : QuoteResult.Ok(quote); } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { _metrics.Increment("quotes.timeout"); return QuoteResult.Fail("provider_timeout"); } catch (OperationCanceledException) { // User/request aborted throw; } }
Why it matters: Requests fail fast instead of hanging, cancellations are
respected, and timeout failures are explicit and measurable.
03
Unit Testing
Tests that prove behaviour, catch edge cases, and allow for safe implementation of new features.
Testing
Assert expected outcomes
Clean test structure that reads like documentation.
CartTests.csNUnit
[Test] public void ApplyingDiscount_ReducesTotal() { // Arrange var cart = new Cart(); cart.Add(new Product("Keyboard", price: 80.00m)); cart.Add(new Product("Mouse", price: 40.00m)); var discount = new PercentOff(0.25m); // Act cart.ApplyDiscount(discount); // Assert Assert.That(cart.Total, Is.EqualTo(90.00m)); Assert.That(cart.DiscountApplied, Is.True); } [Test] public void EmptyCart_HasZeroTotal() { var cart = new Cart(); Assert.That(cart.Total, Is.EqualTo(0m)); Assert.That(cart.ItemCount, Is.EqualTo(0)); }
Why it matters: Anyone reading this knows instantly what's being set up,
what triggers the behaviour, and what the expected outcome is.
Testing
Mocking Dependencies
Isolate the thing you're testing so external services don't interfere.
NotificationTests.csNUnit + Moq
[Test] public void CompleteOrder_SendsEmail() { // Arrange var mockMailer = new Mock<IMailer>(); var service = new OrderService(mockMailer.Object); var order = new Order { Email = "buyer@test.com", Items = { "widget" } }; // Act service.Complete(order); // Assert mockMailer.Verify(m => m.Send( "buyer@test.com", "Order Confirmed", It.IsAny<string>()), Times.Once); } [Test] public void InvalidOrder_DoesNotSendEmail() { var mockMailer = new Mock<IMailer>(); var service = new OrderService(mockMailer.Object); var badOrder = new Order { Email = null }; Assert.Throws<InvalidOrderException>( () => service.Complete(badOrder)); mockMailer.Verify(m => m.Send( It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never); }
Why it matters: Testing OrderService logic, not the actual email system.
Tests run instantly and won't break if the mail server is down.
04
Architecture
System-level structure that keeps code testable, replaceable, and maintainable as complexity grows.
Architecture
Dependency Injection
Depend on abstractions so behaviour can be swapped and tested without changing business
logic.
HardWired.cs✗ Before
public class InvoiceService { private readonly SmtpClient _smtp = new(); private readonly SqlInvoiceRepo _repo = new(); public async Task SendInvoice(Invoice i) { await _repo.Save(i); await _smtp.SendMailAsync(i.Email, "Invoice", "Attached"); } }
Injected.cs✓ After
public class InvoiceService { private readonly IInvoiceRepository _repo; private readonly IEmailSender _email; public InvoiceService(IInvoiceRepository repo, IEmailSender email) { _repo = repo; _email = email; } public async Task SendInvoice(Invoice i) { await _repo.Save(i); await _email.Send(i.Email, "Invoice", "Attached"); } } // composition root services.AddScoped<IInvoiceRepository, SqlInvoiceRepo>(); services.AddScoped<IEmailSender, SmtpEmailSender>();
Why it matters: Swappable dependencies, faster unit tests, and no hidden
infrastructure coupling inside core services.
Architecture
SOLID and SRP
Keep each class focused on one reason to change and move unrelated concerns into dedicated
collaborators.
GodService.cs✗ Before
public class OrderService { public async Task PlaceOrder(Order o) { Validate(o); await _db.Save(o); await _payment.Charge(o.Total); await _mailer.Send(o.Email, "Order placed"); _logger.LogInformation("Order {Id} placed", o.Id); } }
FocusedServices.cs✓ After
public class OrderApplicationService { private readonly IOrderValidator _validator; private readonly IOrderRepository _repo; private readonly IPaymentGateway _payments; private readonly IOrderNotifier _notifier; public async Task PlaceOrder(Order o) { _validator.Validate(o); await _payments.Charge(o.Total); await _repo.Save(o); await _notifier.NotifyPlaced(o); } }
Why it matters: Small focused units are easier to reason about, test, and
modify without collateral regressions.
Architecture
Hexagonal Architecture (Ports and Adapters)
Keep domain logic independent of frameworks by driving it through ports and plugging in
adapters at the edges.
FrameworkCoupled.cs✗ Before
public class TransferController : ControllerBase { [HttpPost] public async Task<IActionResult> Transfer(TransferDto dto) { var from = await _db.Accounts.FindAsync(dto.FromId); var to = await _db.Accounts.FindAsync(dto.ToId); from.Balance -= dto.Amount; to.Balance += dto.Amount; await _db.SaveChangesAsync(); return Ok(); } }
Hexagonal.cs✓ After
public interface IAccountRepositoryPort { Task<Account?> Get(AccountId id); Task Save(Account account); } public class TransferFundsUseCase { private readonly IAccountRepositoryPort _accounts; public TransferFundsUseCase(IAccountRepositoryPort accounts) => _accounts = accounts; public async Task Execute(TransferCommand cmd) { var from = await _accounts.Get(cmd.From); var to = await _accounts.Get(cmd.To); from!.Debit(cmd.Amount); to!.Credit(cmd.Amount); await _accounts.Save(from); await _accounts.Save(to); } } // Adapter implementation plugs into EF Core, Dapper, etc.
Why it matters: The core use case stays framework-agnostic, so storage or
transport changes don’t force domain rewrites.