Section 24

Software design & coding fundamentals

How to write code that stays easy to change: the core design principles, SOLID, the classic design patterns as they show up in .NET, the smells to catch in code review, and the Big-O and problem-solving patterns you need for a live coding round. Examples use modern C# (.NET 10 / C# 14) and banking scenarios.

24.1 Design principles Added

A handful of rules of thumb that keep code easy to change. Each one is good in moderation and harmful when pushed to the extreme.

πŸ” DRY

Don't repeat yourself. Each piece of knowledge (a rule, a formula) lives in one place.

πŸ’‘ KISS

Keep it simple. The simplest design that meets today's needs wins.

βœ‹ YAGNI

You aren't gonna need it. Don't build for a future that may never come.

🧱 Separation of concerns

UI, business rules and data access live in different places, so each can change alone.

🧲 High cohesion, low coupling

Things that change together stay together. Modules know as little about each other as possible.

🧩 Composition over inheritance

Build behaviour by combining small objects, not by deep class trees.

πŸ—£ Law of Demeter

Talk to your direct friends only. Avoid a.B.C.D.Do() train wrecks.

πŸ“£ Tell, don't ask

Tell an object what to do. Don't pull its data out and decide for it.

🚨 Fail fast

Reject bad input at the edge, loudly, before it spreads and corrupts data.

When each one is over-applied

PrincipleOver-applied looks likeBalance
DRYTwo services share a "common" helper and can no longer change aloneDuplicate code is fine if the reasons differ. Rule of three
KISSNo error handling, no tests, "simple" copy-pasteSimple β‰  easy. Simple means few moving parts
YAGNIIgnoring known needs: security, audit, idempotencySkip speculative features, not known non-functionals
Separation of concernsSeven layers to save one rowSeparate by change rate. Vertical slices are fine
Low couplingInterfaces and events everywhere; nobody can follow a callDecouple across boundaries, not inside one module
CompositionTwenty tiny objects for one ruleShallow inheritance (one level) is still fine
Law of DemeterWrapper methods that just forward every callApplies to behaviour, not to DTOs and fluent APIs
Fail fastOne bad record stops a 1-million-row batchFail fast on requests; isolate and park bad records in batches
  • All principles serve one goal: cheap, safe change. Use that to settle arguments.
  • DRY is about knowledge, not text. Two identical lines with different reasons to change are not duplication.
  • YAGNI never applies to security, observability or data integrity.
How to implement it
Library / APIPackageUse it for
Guard helpersbuilt-in ArgumentNullException.ThrowIfNull, ArgumentOutOfRangeException.ThrowIfNegativeOrZeroFail fast in one line per check
Roslyn analyzersbuilt-in <AnalysisLevel>latest-recommended</AnalysisLevel> + .editorconfigCatch smells at build time; CA1506 flags heavy class coupling
Nullable reference typesbuilt-in <Nullable>enable</Nullable>The compiler fails fast on null mistakes
Architecture testsTngTech.ArchUnitNET.xUnitUnit tests that enforce layering (e.g. Domain must not reference Infrastructure)
public sealed class Order(Wallet wallet)
{
    public void Pay(decimal total, string reference)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(total);   // fail fast
        ArgumentException.ThrowIfNullOrWhiteSpace(reference);
        wallet.Debit(total, reference);    // tell, don't ask: Wallet checks funds
    }
}

Every design principle is right… until you push it too far βš–οΈ

πŸ” DRY: share knowledge, not text. A "common" library can couple two teams forever

βœ‹ YAGNI: skip speculative features, never skip security or audit

🧲 Low coupling: decouple across boundaries, not inside one small module

πŸ“£ Tell, don't ask: order.Pay(total) beats order.Customer.Wallet.Balance -= total

🚨 Fail fast on requests, but park bad rows in a batch instead of stopping it

The real goal is cheap, safe change. Which principle have you seen overdone?

#SoftwareDesign #CleanCode #DotNet #Architecture

24.2 SOLID Added

Five object-oriented principles that keep classes small, swappable and testable, so a change in one place doesn't ripple everywhere.

PrincipleSmell it preventsIn ASP.NET Core
S Β· Single responsibilityGod classes, merge conflicts, huge test setupsThin endpoints; one handler or service per use case
O Β· Open/closedGrowing switch statements, risky editsRegister many IFeeRules; inject IEnumerable<T> or keyed services
L Β· Liskov substitutionNotSupportedException overrides, type checksAny registered implementation must work in every consumer
I Β· Interface segregationFat interfaces, fake methods in mocksIOptions<T> per feature, small service interfaces
D Β· Dependency inversionnew of infrastructure inside business codeConstructor injection via the built-in container

S Β· Single responsibility

// ❌ data, fees, PDF and email together
class StatementService
{
    void Load() { }   void CalcFees() { }
    void ToPdf() { }  void Email() { }
}
// βœ… one reason to change each
class FeeCalculator { }
class StatementPdf { }
class StatementMailer { }

O Β· Open/closed

// ❌ edit for every new rail
decimal Fee(Payment p) => p.Rail switch
{
    "NPP" => 0m, "BPAY" => 0.50m,
    _ => throw new NotSupportedException()
};
// βœ… add a class, edit nothing
interface IFeeRule { decimal Fee(Payment p); }
sealed class NppFee : IFeeRule
{
    public decimal Fee(Payment p) => 0m;
}

L Β· Liskov substitution

// ❌ subtype breaks the base promise
class ReadOnlyAccount : Account
{
    public override void Deposit(decimal a)
        => throw new NotSupportedException();
}
// βœ… split the abstraction
interface IReadableAccount
{
    decimal Balance { get; }
}
interface IDepositable { void Deposit(decimal a); }

I Β· Interface segregation

// ❌ report job must "implement" Pay()
interface IAccountService
{
    Account Get(int id); void Pay(Payment p);
    void Close(int id);  Report Export();
}
// βœ… small, client-shaped interfaces
interface IAccountReader { Account Get(int id); }
interface IPaymentSender { void Pay(Payment p); }

D Β· Dependency inversion

// ❌ business code news up the detail
class TransferService
{
    readonly SqlAccountRepo repo = new();
}
// βœ… depend on an abstraction
class TransferService(IAccountRepository repo)
{
}
  • DIP is the principle; DI (the container) is just one way to apply it.
  • Add interfaces at seams: I/O, external systems, things you swap or fake in tests. Not for every class.
  • LSP violations show up as type checks and NotSupportedException. Fix the hierarchy, not the callers.
How to implement it
Library / APIPackageUse it for
Dependency injectionbuilt-in Microsoft.Extensions.DependencyInjectionConstructor injection, lifetimes (D)
Many implementationsbuilt-in IEnumerable<T> injection, keyed services (AddKeyedScoped)Plug in new rules without edits (O)
Options patternbuilt-in IOptions<T>, IOptionsMonitor<T>Small config objects per feature (I)
Assembly scanningScrutorRegister every IFeeRule by convention
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IAccountRepository, SqlAccountRepository>();  // D
builder.Services.AddSingleton<IFeeRule, NppFee>();    // O: add a rule,
builder.Services.AddSingleton<IFeeRule, BpayFee>();   // edit nothing else
builder.Host.UseDefaultServiceProvider(o =>
{
    o.ValidateScopes = true;    // catch scoped services captured by singletons
    o.ValidateOnBuild = true;   // fail fast on missing registrations
});
// A consumer asks for IEnumerable<IFeeRule> to get every rule.

An interface with one implementation and no test fake is ceremony. Extract it when a second implementation or a seam appears.

SOLID in one line each, with the smell it kills 🧱

πŸ…’ Single responsibility: one reason to change. Kills god classes

πŸ…ž Open/closed: add a class, don't edit the switch. Kills risky edits

πŸ…› Liskov: subtypes keep promises. Kills NotSupportedException overrides

πŸ…˜ Interface segregation: small interfaces. Kills fake methods in mocks

πŸ…“ Dependency inversion: depend on abstractions. Kills new SqlRepo() in business code

DI is a tool for D, not the whole of SOLID. Which letter do you see broken most?

#SOLID #DotNet #CSharp #SoftwareDesign

24.3 Creational patterns Added

Patterns that control how objects get created, so callers don't depend on concrete classes or complex setup steps.

PatternProblem it solvesWhere .NET uses it
Factory methodCaller shouldn't pick the concrete classILoggerFactory.CreateLogger, IHttpClientFactory.CreateClient
Abstract factoryCreate a family of related objects that must matchDbProviderFactory (connection + command + parameter)
BuilderMany optional steps; readable setupWebApplication.CreateBuilder, StringBuilder, UriBuilder, DbContextOptionsBuilder
SingletonExactly one shared instanceAddSingleton, ArrayPool<T>.Shared, TimeProvider.System
PrototypeCopy an existing object, then tweak itRecord with expressions

❌ Static singleton

  • static readonly Instance: hidden global dependency.
  • Can't swap it in tests; state leaks between tests.
  • Hand-rolled thread safety (Lazy<T>, locks).

βœ… DI singleton

  • AddSingleton<IFxCache, FxCache>(): visible in the constructor.
  • Swap for a fake in tests; one line to change lifetime.
  • The container handles thread-safe creation and disposal.
  • In ASP.NET Core the DI container is your main factory. Write your own factory only when the choice depends on runtime data.
  • A singleton must be thread-safe and must never hold a scoped service (e.g. a DbContext).
  • Builders shine for test data: new PaymentBuilder().WithAmount(250m).Build().
How to implement it
Library / APIPackageUse it for
Factory delegatesbuilt-in AddSingleton<T>(sp => …)Pick an implementation from config at startup
ActivatorUtilitiesbuilt-in Microsoft.Extensions.DependencyInjectionCreate a type with DI services plus runtime arguments
IHttpClientFactorybuilt-in Microsoft.Extensions.HttpFactory that pools handlers and avoids socket exhaustion
Object poolingMicrosoft.Extensions.ObjectPool; built-in ArrayPool<T>Reuse expensive objects and buffers on hot paths
// Factory delegate: the container builds the right client from config
builder.Services.AddSingleton<IFxRateClient>(sp =>
{
    var opts = sp.GetRequiredService<IOptions<FxOptions>>().Value;
    return opts.UseStub ? new StubFxClient() : new LiveFxClient(opts.BaseUrl);
});

// Prototype with records: copy, then change one field
public sealed record Payment(string Id, decimal Amount, int Attempt);
var retry = payment with { Attempt = payment.Attempt + 1 };

Stop writing static singletons in .NET πŸ›‘

🏭 Factory method: ILoggerFactory, IHttpClientFactory already do it for you

🧰 Builder: WebApplication.CreateBuilder is the pattern you use every day

1️⃣ Singleton: AddSingleton beats a static field. Testable, visible, one line to change

🧬 Prototype: a record with expression is a safe copy

In ASP.NET Core the DI container is the factory. What do you still create with new?

#DesignPatterns #DotNet #DependencyInjection #CSharp

24.4 Structural patterns Added

Patterns that wrap or combine objects to change an interface, add behaviour, or hide complexity, without editing the original class.

πŸ”Œ Adapter

Makes an existing class fit the interface you need. Wrap a vendor SDK behind IFraudScorer.

🎁 Decorator

Same interface, extra behaviour: caching, logging, retries. Stack them like layers.

πŸ› Facade

One simple entry point over many subsystems. OnboardingService calls KYC, core banking and email.

πŸ›‘ Proxy

Same interface, controls access: lazy loading, remote calls, permission checks.

🌳 Composite

Treat a tree of items like one item. A product bundle's price is the sum of its parts.

PatternInterfaceUse when.NET example
AdapterChanges itThird-party or legacy API doesn't match yoursStreamReader over a Stream
DecoratorSame, adds behaviourCross-cutting concerns without touching the classDelegatingHandler, GZipStream, DelegatingChatClient
ProxySame, controls accessLazy load, remote call, security checkEF Core lazy-loading proxies, gRPC client, DispatchProxy
FacadeNew, simplerCallers need one task, not ten subsystemsFile.ReadAllTextAsync, HttpClient
CompositeSame for leaf and groupTree structures: menus, bundles, rulesCompositeFileProvider, configuration sections
  • Decorator vs proxy: same shape. A decorator adds behaviour; a proxy controls access to the real object.
  • Put an adapter around every vendor SDK. Swapping vendors then touches one class (an anti-corruption layer in DDD terms).
  • Order matters when stacking decorators: cache outside retry, or retry outside cache? Decide on purpose.
How to implement it
Library / APIPackageUse it for
Scrutor DecorateScrutorWrap any registered service with a decorator
DelegatingHandlerbuilt-in System.Net.Http + AddHttpMessageHandlerDecorators around every outbound HTTP call
IChatClient middlewareMicrosoft.Extensions.AIChatClientBuilder stacks caching, telemetry, tool calling
DispatchProxybuilt-in System.ReflectionRuntime proxy for any interface (not AOT-friendly)
Lazy-loading proxiesMicrosoft.EntityFrameworkCore.ProxiesProxy pattern in EF Core; watch for N+1 queries
builder.Services.AddScoped<IFxRateService, FxRateService>();
builder.Services.Decorate<IFxRateService, CachedFxRateService>();   // Scrutor
builder.Services.Decorate<IFxRateService, LoggedFxRateService>();   // outermost

builder.Services.AddTransient<CorrelationIdHandler>();
builder.Services.AddHttpClient<FxApiClient>()
    .AddHttpMessageHandler<CorrelationIdHandler>();   // decorates HttpClient

builder.Services.AddChatClient(innerClient)   // IChatClient decorators
    .UseDistributedCache()
    .UseOpenTelemetry()
    .UseFunctionInvocation();

Four patterns that look the same but aren't πŸŽπŸ›‘πŸ”ŒπŸ›

🎁 Decorator: same interface, adds behaviour (cache, log, retry)

πŸ›‘ Proxy: same interface, controls access (lazy load, remote, auth)

πŸ”Œ Adapter: changes the interface to fit yours (wrap that vendor SDK!)

πŸ› Facade: one simple door into many subsystems

In .NET you already use them: DelegatingHandler, EF Core proxies, StreamReader, HttpClient. Which one do you reach for most?

#DesignPatterns #DotNet #CSharp #SoftwareArchitecture

24.5 Behavioural patterns Added

Patterns about how objects share work and talk: swapping algorithms, reacting to events, and passing a request along a pipeline.

PatternIdea.NET example
StrategySwap an algorithm behind one interfaceKeyed services per payment rail; IComparer<T>
ObserverSubscribers react when something happensC# event, IObservable<T>, domain events
MediatorObjects talk through one hub, not to each otherRequest/handler libraries (see below)
CommandA request as an object: queue, log, undo, retryrecord TransferFunds(…) sent to a queue
Chain of responsibilityEach link handles or passes onASP.NET Core middleware, DelegatingHandler pipeline
Template methodBase class fixes the steps, subclass fills one inBackgroundService.ExecuteAsync
StateBehaviour depends on current state; explicit transitionsPayment lifecycle; Stateless library
SpecificationA business rule as a reusable, combinable objectArdalis.Specification for EF Core queries

State: a payment lifecycle

Created
β†’
Authorised
β†’
Submitted
β†’
Settled
β‡’
Returnedonly from Settled

Model allowed transitions explicitly. "Settle a payment that was never authorised" should be impossible, not just "checked somewhere".

βš–οΈ MediatR licence change

MediatR v13.0 and later (July 2025, Lucky Penny Software) is dual-licensed: RPL-1.5 or a paid commercial licence. A free Community licence covers organisations under US$5M annual revenue (plus other criteria). Versions before 13 stay under their original open-source licence but get no updates. Free alternatives: Mediator by martinothamar (source-generated, MIT), Wolverine (MIT), or plain handler classes injected directly.

  • Strategy + DI = open/closed in practice. Keyed services (.NET 8+) pick the strategy by name.
  • You may not need a mediator at all. Injecting TransferHandler directly is simpler and easier to navigate.
  • Chain of responsibility is the shape of every ASP.NET Core request: middleware order is behaviour.
How to implement it
Library / APIPackageUse it for
Keyed servicesbuilt-in AddKeyedScoped, [FromKeyedServices]Strategy chosen by key
Observerbuilt-in event, IObservable<T>; System.ReactiveIn-process events and reactive streams
Mediator (source-generated)Mediator.SourceGenerator + Mediator.AbstractionsMIT, MediatR-like API, AOT-friendly
MediatRMediatR commercial licencev13+: paid, or free Community tier if eligible
State machineStatelessExplicit states, triggers and guards
SpecificationArdalis.Specification, Ardalis.Specification.EntityFrameworkCoreReusable query rules for repositories
builder.Services.AddKeyedScoped<IPaymentRail, NppRail>("npp");
builder.Services.AddKeyedScoped<IPaymentRail, BpayRail>("bpay");

// Strategy fixed per endpoint
app.MapPost("/pay/npp",
    ([FromKeyedServices("npp")] IPaymentRail rail, PayRequest req) =>
        rail.SendAsync(req));

// Strategy chosen at runtime from the request
app.MapPost("/pay", (PayRequest req, IServiceProvider sp) =>
    sp.GetRequiredKeyedService<IPaymentRail>(req.Rail).SendAsync(req));

You're already using behavioural patterns every day in ASP.NET Core πŸ”„

β›“ Chain of responsibility: middleware and DelegatingHandler

🎯 Strategy: keyed services, one per payment rail

πŸ“£ Observer: domain events after a transfer

🧭 State: Created β†’ Authorised β†’ Settled, with illegal moves made impossible

βš–οΈ Heads-up: MediatR v13+ needs a commercial licence unless you qualify for the free Community tier. Mediator (source-generated, MIT) and Wolverine are free options

Do you still need a mediator, or would plain handlers do?

#DesignPatterns #DotNet #MediatR #CSharp

24.6 Anti-patterns & code smells Added

Warning signs that code will be hard and risky to change, and the small, safe refactoring moves that fix them.

SmellSymptomRefactoring move
God class3,000-line PaymentService, 20 dependenciesExtract class per responsibility
Anaemic domain modelEntities are bags of setters; rules live in servicesMove behaviour into the entity (account.Debit())
Primitive obsessionstring bsb, string acct, decimal amt everywhereValue objects: Bsb, Money
Shotgun surgeryOne change edits ten filesMove related code together (cohesion)
Feature envyA method uses another class's data more than its ownMove method to that class
Long parameter listSix or more arguments, easy to swap by mistakeIntroduce parameter object (a record)
Magic numbersif (amount > 10000)Named constant or options value
Leaky abstractionRepository returns IQueryable; SQL errors reach the UIReturn results, translate errors at the boundary
Pattern-itisFactory of strategy of decorator for one ifInline it. Add patterns when a second case appears

🩸 Anaemic model: a smell when…

  • Rules (overdraft limits, state changes) are spread across services.
  • Anyone can set Balance directly and break invariants.

πŸ‘ Anaemic model: fine when…

  • It's CRUD, reporting or a simple data API with no real rules.
  • Records are DTOs crossing a boundary, not domain objects.
  • Refactor in small, behaviour-preserving steps with tests green after each one.
  • Value objects kill primitive obsession and make invalid data unrepresentable.
  • Over-engineering is a smell too. Patterns should remove pain you already have.
How to implement it
Library / APIPackageUse it for
IDE refactoringsbuilt-in Visual Studio / VS Code C# Dev Kit / RiderExtract method, extract interface, move type, rename safely
Code metricsMicrosoft.CodeAnalysis.Metrics; CA1502 / CA1506 rulesFind complex and over-coupled classes in CI
Value objectsbuilt-in readonly record struct; VogenReplace primitives; Vogen generates validation and converters
// ❌ Pay(string bsb, string acct, decimal amt, string cur, string reference)
// βœ… value object + parameter object
public readonly partial record struct Bsb
{
    public string Value { get; }
    public Bsb(string value) => Value = Format().IsMatch(value)
        ? value : throw new ArgumentException("BSB must be 000-000", nameof(value));

    [GeneratedRegex(@"^\d{3}-\d{3}$")]
    private static partial Regex Format();
}
public sealed record PaymentRequest(Bsb Bsb, string Account, decimal Amount,
    string Reference);

Nine code smells I flag in almost every review πŸ‘ƒ

🐘 God class: 3,000 lines, 20 dependencies β†’ extract class

πŸ”’ Primitive obsession: string bsb, string acct β†’ value objects

πŸ”« Shotgun surgery: one change, ten files β†’ move related code together

πŸ“œ Long parameter list β†’ introduce a parameter object (a record)

πŸ— Pattern-itis: a factory of strategies for one if β†’ inline it

Refactor in tiny steps with tests green after each. Which smell bugs you most?

#CleanCode #Refactoring #DotNet #CodeReview

24.7 Clean code & code review Added

Code is read far more often than it is written. Clean code makes intent obvious, and a good review checks more than style.

🏷 Naming

Names say intent and domain: settlementDate, not d2. Booleans read as questions: isOverdrawn.

βœ‚οΈ Small functions

One level of abstraction per method. If you need "and" to describe it, split it.

πŸšͺ Guard clauses

Return or throw early. Keep the happy path un-nested at the left margin.

🧊 Immutability

record, init, required. C# 14's field keyword lets a property validate without a manual backing field.

❔ Nullability

Turn on nullable reference types. Treat warnings as errors in new projects.

πŸ’¬ Comments explain why

The code says what. Comments say why: a regulation, a workaround, a trade-off.

Errors: exceptions or Result?

SituationUseWhy
Expected business outcome (insufficient funds, invalid BSB)Result typePart of the normal flow; caller must handle it; cheap
Bug or broken invariantExceptionShould never happen; fail fast and loudly
Infrastructure failure (DB down, timeout)Exception (+ retry policy)Let resilience and the global handler deal with it
At the HTTP edgeProblemDetails (RFC 9457)One consistent error shape for every client
  • Automate style (formatters, analyzers) so humans review design, correctness and risk.
  • Use a Result type for expected outcomes, exceptions for the unexpected. Don't use exceptions for control flow.
  • Small PRs (under ~400 lines) get better reviews than big ones.
How to implement it
Library / APIPackageUse it for
Nullable + warnings as errorsbuilt-in <Nullable>, <TreatWarningsAsErrors>Null safety enforced by the compiler
Formattingbuilt-in dotnet format + .editorconfigStyle checked in CI, not in review comments
Typed resultsbuilt-in TypedResults, Results<T1, T2>Each endpoint outcome visible in the signature and OpenAPI
ProblemDetailsbuilt-in AddProblemDetails(), IExceptionHandlerOne error format; global handler for exceptions
Result patternErrorOr or FluentResultsReturn success or typed errors without exceptions
public ErrorOr<Transfer> Create(Account from, decimal amount)
{
    if (amount <= 0) return Error.Validation("amount", "Amount must be positive");
    if (from.Balance < amount) return Error.Conflict("funds", "Insufficient funds");
    return new Transfer(Guid.NewGuid(), from.Id, amount);   // happy path last
}

app.MapPost("/transfers", (TransferRequest req, Accounts accounts, TransferService svc) =>
    svc.Create(accounts.Get(req.FromId), req.Amount).Match<IResult>(
        t => TypedResults.Created($"/transfers/{t.Id}", t),
        errors => TypedResults.Problem(errors[0].Description,
            statusCode: errors[0].Type == ErrorType.Validation ? 400 : 409)));

My 7-point code review checklist βœ…

🎯 Correctness: edge cases like empty, zero, duplicates, time zones

πŸ” Security: authorisation on every endpoint, no PII in logs

⚑ Performance: no N+1 queries, no sync-over-async

πŸ“– Readability: names that explain intent, small methods

πŸ§ͺ Tests and πŸ”­ observability: would we know if it broke in prod?

Let formatters and analyzers argue about style, so humans can review design and risk. What's on your list?

#CodeReview #CleanCode #DotNet #SoftwareEngineering

24.8 Big-O & core data structures Added

Big-O describes how work grows as input grows. Picking the right collection is usually the cheapest performance win there is.

Big-ONameExamplen = 1,000,000
O(1)ConstantDictionary lookup, array index1 step
O(log n)LogarithmicBinary search, B-tree index seek~20 steps
O(n)LinearScan a list, List.Contains1 million
O(n log n)LinearithmicArray.Sort, merge sort~20 million
O(nΒ²)QuadraticNested loops, Contains inside a loop10ΒΉΒ², far too slow
O(2ⁿ)ExponentialAll subsets, naive recursionImpossible

.NET collections

CollectionFindAdd / removeUse when
List<T>O(1) by index, O(n) by valueAdd O(1)*, insert/remove middle O(n)Default ordered list
Dictionary<TKey,TValue>O(1) avgO(1) avgLookup by key
HashSet<T>O(1) avgO(1) avgUniqueness, "seen before?"
SortedDictionary / SortedSetO(log n)O(log n)Kept in key order; ranges, min/max
Queue<T> / Stack<T>Peek O(1)O(1)*FIFO (BFS, work) / LIFO (DFS, undo)
LinkedList<T>O(n)O(1) at a known nodeLRU cache (with a dictionary)
PriorityQueue<TElement,TPriority>Peek min O(1)O(log n)Top-k, scheduling, Dijkstra
FrozenDictionary / FrozenSetO(1), fastest readsRead-only; slow to buildStatic lookups built once at startup (.NET 8+)
ImmutableArray<T>O(1) by index"Add" copies: O(n)Thread-safe snapshots, rarely changed

* Amortised: usually O(1), with an occasional O(n) resize. Set the capacity up front when you know the size.

  • List.Contains inside a loop is O(nΒ²). Switch to a HashSet and it becomes O(n).
  • Dictionary O(1) is average. A bad GetHashCode (or keys you mutate) makes it slow or wrong.
  • Big-O hides constants. For tiny n, a simple array scan can beat a dictionary. Measure with BenchmarkDotNet.
How to implement it
Library / APIPackageUse it for
Frozen collectionsbuilt-in System.Collections.FrozenRead-heavy lookups (currency codes, config maps)
Immutable collectionsbuilt-in System.Collections.ImmutableSafe sharing across threads
Concurrent collectionsbuilt-in System.Collections.ConcurrentConcurrentDictionary for shared mutable state
BenchmarkingBenchmarkDotNetProve a collection change actually helps
using System.Collections.Frozen;

// Built once at startup, read millions of times
FrozenSet<string> supported = new[] { "AUD", "NZD", "USD", "EUR" }
    .ToFrozenSet(StringComparer.OrdinalIgnoreCase);

// ❌ O(n²): List.Contains inside a loop
// βœ… O(n): HashSet lookups
var seen = new HashSet<string>(capacity: payments.Count);
foreach (var p in payments)
    if (!seen.Add(p.IdempotencyKey)) Reject(p, "duplicate");

One line of code turned a 40-minute batch into 4 seconds ⏱

🐒 Before: list.Contains(key) inside a loop = O(n²)

πŸš€ After: hashSet.Add(key) = O(n)

🧊 Static lookups? FrozenDictionary / FrozenSet (.NET 8+) are built for reads

πŸ” Top-k or scheduling? PriorityQueue<TElement,TPriority>

Big-O hides constants, so measure with BenchmarkDotNet. What's your favourite "wrong collection" story?

#BigO #DotNet #Performance #DataStructures

24.9 Coding interview patterns Added

Most live-coding problems are a variation of about ten patterns. Spot the cue, name the pattern, state the complexity, then code.

PatternCue in the questionTypical complexity
Two pointersSorted array, pairs, palindromes, in-placeO(n) time, O(1) space
Sliding window"Longest / shortest subarray or substring that…"O(n)
Hash map countingDuplicates, anagrams, "two sum", frequencyO(n) time, O(n) space
BFSShortest path in an unweighted graph or grid, levelsO(V + E)
DFSAll paths, connected components, tree traversalO(V + E)
Binary searchSorted input, or "smallest value that works"O(log n)
Heap / top-k"k largest", "k closest", merge k listsO(n log k)
Dynamic programming"Number of ways", min/max cost, overlapping subproblemsOften O(n) or O(nΒ·m)
BacktrackingAll combinations, permutations, subsetsExponential; prune early
IntervalsMeetings, overlaps, merge rangesO(n log n) (sort first)
Union-find"Are these connected?" with merges; near O(1) per opO(Ξ±(n)) per op

A 5-step approach for live coding

  • Say the brute force and its Big-O first, then improve. Silence is the most common reason people fail.
  • "Longest substring", "k largest", "shortest path" are cues: sliding window, heap, BFS.
  • Use the built-in collections confidently: PriorityQueue, HashSet, Queue.
How to implement it
PatternLibrary / APINotes
Heap / top-kbuilt-in PriorityQueue<TElement,TPriority>Min-heap. For a max-heap, pass a reversed comparer
Binary searchbuilt-in Array.BinarySearch, List<T>.BinarySearchReturns the bitwise complement of the insert point when not found
Countingbuilt-in Enumerable.CountBy (.NET 9+), DictionaryFrequency maps in one line
BFS / DFSbuilt-in Queue<T>, Stack<T>, HashSet<T>Queue for BFS, stack or recursion for DFS, set for visited
// Sliding window: longest substring with no repeated chars. O(n)
static int LongestDistinct(string s)
{
    var last = new Dictionary<char, int>();
    int best = 0, start = 0;
    for (int i = 0; i < s.Length; i++)
    {
        if (last.TryGetValue(s[i], out int seen) && seen >= start) start = seen + 1;
        last[s[i]] = i;
        best = Math.Max(best, i - start + 1);
    }
    return best;
}

// Top-k largest: keep a min-heap of size k. O(n log k)
static int[] TopK(int[] nums, int k)
{
    var heap = new PriorityQueue<int, int>();
    foreach (int n in nums)
    {
        heap.Enqueue(n, n);
        if (heap.Count > k) heap.Dequeue();   // drop the smallest
    }
    return [.. heap.UnorderedItems.Select(x => x.Element)];
}

Live coding is pattern matching, not genius 🧠

πŸͺŸ "Longest substring that…" β†’ sliding window, O(n)

πŸ—‚ "Duplicates / two sum / anagram" β†’ hash map, O(n)

πŸ” "k largest / k closest" β†’ heap, O(n log k)

🌊 "Shortest path in a grid" β†’ BFS, O(V + E)

πŸ—£ And always: clarify β†’ examples β†’ brute force β†’ optimise β†’ test edge cases

Say the brute force first. Which pattern catches you out most?

#CodingInterview #Algorithms #DotNet #CSharp

⚑ Quick-fire Q&A

When is duplicated code acceptable?

When the two copies have different reasons to change. DRY is about knowledge, not text. Merging two "similar" fee calculations owned by different products couples them forever. A common rule: wait for the third copy before abstracting.

What's the difference between dependency inversion and dependency injection?

Dependency inversion is the principle: high-level code depends on abstractions, not on details. Dependency injection is a technique: dependencies are passed in (usually via the constructor) instead of created inside. The ASP.NET Core container automates DI, which makes DIP easy to apply.

Why is a DI singleton better than a static singleton?

It's visible in the constructor, easy to replace with a fake in tests, and its lifetime is one line of config. The container also handles thread-safe creation and disposal. A static singleton is a hidden global that leaks state between tests.

Decorator, proxy or adapter?

Adapter changes the interface to the one you need. Decorator keeps the interface and adds behaviour (caching, logging). Proxy keeps the interface and controls access to the real object (lazy loading, remote calls, permission checks).

Should a new .NET project use MediatR?

Check the licence first: MediatR v13+ needs a paid commercial licence unless you qualify for the free Community tier (under US$5M revenue, plus other criteria). Free options are the source-generated Mediator (MIT), Wolverine, or simply injecting handler classes directly, which is often the clearest choice.

Is an anaemic domain model always bad?

No. For CRUD, reporting or thin data APIs with few rules, plain data classes plus services are simpler. It becomes a problem when real business rules and invariants (overdraft limits, state changes) are scattered across services and anyone can set fields directly.

Exceptions or the Result pattern?

Use a Result type (e.g. ErrorOr, FluentResults) for expected business outcomes like insufficient funds. Use exceptions for bugs, broken invariants and infrastructure failures, and turn them into ProblemDetails in a global handler. Don't use exceptions for normal control flow.

Why can a Dictionary lookup be slower than O(1)?

O(1) is the average case. Many keys with the same hash code (a poor GetHashCode) fall into the same bucket and lookups degrade towards O(n). Mutating a key after inserting it can make the entry impossible to find. Use immutable keys such as records or strings.

← 23 Β· .NET Engineering Deep Dive