Design Patterns in .NET
Practical usage, trade-offs, and real-world design in production systems
Design patterns are one of those topics that are often taught in a way that makes them sound more formal than they really are.
In real production systems, a pattern is usually just this:
a repeatable way to solve a recurring design problem.
That is all.
Good engineers do not start from, “Which GoF pattern can I apply here?” They start from, “What problem keeps happening in this code, and what structure would make it safer, clearer, or easier to change?”
That is the mindset that matters in a technical leadership interview.
Part 1 — Big picture
Why design patterns exist
Patterns exist because large systems keep running into the same categories of problems:
- how to swap one behavior for another
- how to isolate external dependencies
- how to coordinate many moving parts
- how to represent changing state safely
- how to extend behavior without rewriting core logic
- how to avoid spreading conditional logic everywhere
When a system is small, you can often solve these problems with direct code. When a system becomes large, long-lived, and touched by many engineers, the same problems return again and again. Patterns become useful because they give you a shared design vocabulary and a proven shape for handling those problems.
In a production .NET system, patterns are less about elegance and more about survival.
For example:
- In machine communication, you need a way to isolate vendor SDK quirks from your application logic.
- In workflow orchestration, you need a way to model states and transitions without giant
if/elseblocks. - In WPF UI composition, you need a way to decouple UI actions from business logic.
- In real-time event/result processing, you need a way for components to react to data without hard-wiring everything together.
Patterns help because they reduce chaos.
Patterns are reusable design ideas, not rigid rules
A pattern is not a law. It is not something you “must” implement exactly as described in a book.
In real .NET code, patterns are often softened, mixed, or adapted:
- a Strategy may just be an interface with a few implementations registered in DI
- a Factory may just be a small composition service
- an Observer may be events,
IObservable<T>, Channels, or a message bus - a Decorator may be a pipeline behavior or wrapper service
- a State pattern may be a formal state object model, or it may just be an explicit state machine table
The value is not the name. The value is the problem-solution fit.
Why memorizing names matters less than understanding the problem
Interviewers sometimes ask about patterns, but strong senior answers are rarely about definitions.
Weak answer:
“Strategy is a behavioral pattern that allows selecting an algorithm at runtime.”
Strong answer:
“I use Strategy when the business or hardware behavior changes by mode, device type, or policy, and I don’t want that variation spread across conditionals in multiple places.”
That answer shows design judgment.
What matters is whether you understand:
- what pressure in the system caused the pattern to appear
- what trade-off the pattern introduces
- what simpler alternative exists
- when the pattern stops paying for itself
Why patterns are useful in large systems but dangerous when forced
Patterns help large systems because large systems have:
- more variation
- more dependencies
- more engineers changing code
- longer lifetimes
- more production failures
- more need for testability and extension
But patterns become dangerous when forced too early or too mechanically.
Common failure mode:
A team learns “clean architecture + patterns,” then creates:
- interface for everything
- factories for types that never vary
- repositories for every table
- mediator for trivial in-process calls
- state objects for a workflow with only two stable branches
Now the code has more files, more indirection, more DI registrations, and more debugging pain, but not more clarity.
Patterns should reduce complexity at the system level, not increase it at the local level.
Part 2 — Most important patterns in real .NET systems
I will focus on the patterns that actually matter in modern production .NET systems.
1. Strategy
The real problem it solves
You have a stable workflow, but one part of the behavior varies.
Examples:
- different inspection algorithms
- different classification logic by product type
- different retry policy by device type
- different export format generation
- different rules for defect scoring
Without Strategy, variation often turns into:
- giant
switchblocks - conditionals spread across services
- duplicated workflow logic with only one step different
Strategy keeps the workflow stable and isolates the variable behavior.
How it appears in .NET systems
Usually as:
- an interface
- multiple implementations
- selected by DI, config, machine type, or runtime context
For example:
public interface IInspectionStrategy
{
InspectionResult Inspect(InspectionContext context);
}
public sealed class BrightFieldInspectionStrategy : IInspectionStrategy
{
public InspectionResult Inspect(InspectionContext context)
{
// Bright-field specific inspection logic
return new InspectionResult();
}
}
public sealed class DarkFieldInspectionStrategy : IInspectionStrategy
{
public InspectionResult Inspect(InspectionContext context)
{
// Dark-field specific inspection logic
return new InspectionResult();
}
}Selection may happen through a resolver:
public interface IInspectionStrategyResolver
{
IInspectionStrategy Resolve(InspectionMode mode);
}
public sealed class InspectionStrategyResolver : IInspectionStrategyResolver
{
private readonly IServiceProvider _serviceProvider;
public InspectionStrategyResolver(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IInspectionStrategy Resolve(InspectionMode mode) =>
mode switch
{
InspectionMode.BrightField => _serviceProvider.GetRequiredService<BrightFieldInspectionStrategy>(),
InspectionMode.DarkField => _serviceProvider.GetRequiredService<DarkFieldInspectionStrategy>(),
_ => throw new NotSupportedException($"Unsupported mode: {mode}")
};
}Why it is useful
- keeps varying logic localized
- reduces branching in higher-level workflow code
- makes extension safer
- improves testability
- avoids contaminating orchestration code with mode-specific details
When it is overkill
If there are only two simple cases and they are unlikely to grow, a small conditional may be clearer than a full Strategy structure.
Bad use of Strategy:
- creating five classes for logic that is only three lines long
- using Strategy when there is no real variation pressure
- splitting code so much that understanding the full workflow becomes harder
The pattern is justified when variation is likely to grow or when change frequency differs across behaviors.
2. Adapter
The real problem it solves
An external dependency does not fit your application model.
This is extremely common in industrial and hardware-integrated systems.
Examples:
- vendor SDK uses blocking calls and awkward object models
- camera API exposes callbacks you want to turn into async tasks
- machine controller exposes raw numeric error codes instead of meaningful domain failures
- a legacy COM API returns device-specific structures that pollute your app
Adapter isolates that mismatch.
How it appears in .NET systems
Usually as a wrapper around:
- vendor SDKs
- REST/gRPC clients
- serial or TCP device APIs
- legacy libraries
- third-party services
Example:
public interface IMachineController
{
Task ConnectAsync(CancellationToken cancellationToken);
Task<HomeResult> HomeAsync(CancellationToken cancellationToken);
Task<MachineStatus> GetStatusAsync(CancellationToken cancellationToken);
}
public sealed class VendorMachineControllerAdapter : IMachineController
{
private readonly VendorSdkClient _sdkClient;
public VendorMachineControllerAdapter(VendorSdkClient sdkClient)
{
_sdkClient = sdkClient;
}
public Task ConnectAsync(CancellationToken cancellationToken)
{
return Task.Run(() =>
{
_sdkClient.Initialize();
_sdkClient.OpenConnection();
}, cancellationToken);
}
public Task<HomeResult> HomeAsync(CancellationToken cancellationToken)
{
return Task.Run(() =>
{
int code = _sdkClient.RunHomeSequence();
return code == 0
? HomeResult.Success()
: HomeResult.Failure($"Vendor home failed with code {code}");
}, cancellationToken);
}
public Task<MachineStatus> GetStatusAsync(CancellationToken cancellationToken)
{
return Task.Run(() =>
{
var raw = _sdkClient.ReadStatus();
return new MachineStatus(
isReady: raw.ReadyFlag,
isBusy: raw.MotionState == 2,
alarmCode: raw.AlarmCode);
}, cancellationToken);
}
}Why it is useful
- prevents vendor types from leaking everywhere
- allows replacement or simulation in tests
- gives you a domain-friendly API
- centralizes translation of errors, threading, timeouts, and data conversion
- protects the rest of the app from unstable external interfaces
When it is overkill
If the external library is tiny and used in exactly one place, a full abstraction may not help.
But in industrial systems, Adapter is usually one of the highest-value patterns because vendor integration is one of the biggest sources of long-term pain.
3. Factory / Abstract Factory
The real problem it solves
Object creation has logic, variation, or dependency on runtime context.
Examples:
- different machine models need different service sets
- simulation mode vs real hardware mode
- camera pipeline creation differs by line configuration
- recipe loads determine which processors are needed
Simple constructor calls are fine until creation itself becomes a design concern.
How it appears in .NET systems
In practice, often not as textbook “Abstract Factory” but as:
- a composition service
- named registrations
- a builder/factory abstraction
- a machine-specific component provider
Example:
public interface IMachineModuleFactory
{
IMachineModule Create(MachineConfiguration configuration);
}
public sealed class MachineModuleFactory : IMachineModuleFactory
{
private readonly IServiceProvider _serviceProvider;
public MachineModuleFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IMachineModule Create(MachineConfiguration configuration)
{
return configuration.MachineType switch
{
MachineType.Real => _serviceProvider.GetRequiredService<RealMachineModule>(),
MachineType.Simulator => _serviceProvider.GetRequiredService<SimulatedMachineModule>(),
_ => throw new NotSupportedException($"Machine type {configuration.MachineType} is not supported.")
};
}
}Why it is useful
- keeps creation logic out of business workflows
- centralizes variant selection
- reduces scattered conditionals
- helps avoid direct
newof infrastructure-heavy types in application code
When it is overkill
If construction is trivial and stable, a factory adds ceremony.
A common mistake is creating factories just to hide new. That is not enough reason. A factory is useful when creation itself has meaningful variation or orchestration.
4. Observer / Pub-Sub
The real problem it solves
One component produces events or data, and many other components may react.
Examples:
- machine status updates
- image captured
- inspection result available
- alarm raised
- throughput metric updated
- UI panel needing live updates
Without Observer or Pub-Sub, producers start knowing too much about consumers.
How it appears in .NET systems
Several ways:
- .NET events
IObservable<T>- Channels
- message brokers
- in-process event bus
- mediator notifications
Example with a result publisher:
public interface IInspectionResultPublisher
{
void Publish(InspectionResult result);
}
public sealed class InspectionResultStream : IInspectionResultPublisher
{
private readonly Channel<InspectionResult> _channel = Channel.CreateUnbounded<InspectionResult>();
public ChannelReader<InspectionResult> Reader => _channel.Reader;
public void Publish(InspectionResult result)
{
if (!_channel.Writer.TryWrite(result))
throw new InvalidOperationException("Failed to publish inspection result.");
}
}Consumers:
public sealed class ResultPersistenceWorker
{
private readonly ChannelReader<InspectionResult> _reader;
private readonly IResultRepository _repository;
public ResultPersistenceWorker(InspectionResultStream stream, IResultRepository repository)
{
_reader = stream.Reader;
_repository = repository;
}
public async Task RunAsync(CancellationToken cancellationToken)
{
await foreach (var result in _reader.ReadAllAsync(cancellationToken))
{
await _repository.SaveAsync(result, cancellationToken);
}
}
}Why it is useful
- decouples producer from consumers
- supports live streaming scenarios
- enables adding new consumers without rewriting producer code
- matches real-time processing naturally
When it is overkill
- when there is only one consumer and likely to remain so
- when event flow becomes invisible and debugging gets hard
- when “pub-sub” is used to avoid clear ownership and control flow
Pub-Sub helps, but uncontrolled event-driven design can become a system where nobody knows who reacts to what.
5. State
The real problem it solves
Behavior changes depending on the current state, and invalid transitions matter.
This is extremely important in machine systems.
Examples:
- Disconnected → Connecting → Ready → Running → Paused → Faulted
- Idle → Loading Recipe → Inspecting → Finalizing → Completed
- Alarm states and operator acknowledgment flows
Without an explicit state model, these systems often become:
- booleans everywhere
- flags that contradict each other
- fragile transition logic
- invalid operations slipping through
How it appears in .NET systems
Sometimes as formal state objects, but very often as an explicit state machine with transition rules.
Simple example:
public enum MachineState
{
Disconnected,
Ready,
Running,
Faulted
}
public sealed class MachineStateController
{
public MachineState CurrentState { get; private set; } = MachineState.Disconnected;
public void Connect()
{
if (CurrentState != MachineState.Disconnected)
throw new InvalidOperationException("Machine can only connect from Disconnected state.");
CurrentState = MachineState.Ready;
}
public void StartRun()
{
if (CurrentState != MachineState.Ready)
throw new InvalidOperationException("Machine can only start from Ready state.");
CurrentState = MachineState.Running;
}
public void Fault()
{
CurrentState = MachineState.Faulted;
}
}More advanced version uses per-state classes, but often a clear transition model is enough.
Why it is useful
- makes valid transitions explicit
- prevents impossible combinations
- reduces hidden workflow bugs
- improves recovery logic
- helps UI and operations align with machine reality
When it is overkill
A full State object hierarchy can be too much if:
- workflow is simple
- transitions are few
- state-specific behavior is limited
Sometimes an explicit enum + transition rules is more readable than many tiny state classes.
6. Command
The real problem it solves
An action should be represented as an object or encapsulated unit, separate from UI triggers and sometimes from execution timing.
In WPF, this pattern is everywhere.
Examples:
- Start inspection
- Stop machine
- Acknowledge alarm
- Export results
- Retry connection
How it appears in .NET systems
Most visibly through ICommand in WPF.
public sealed class StartInspectionCommand : ICommand
{
private readonly IInspectionWorkflow _workflow;
public StartInspectionCommand(IInspectionWorkflow workflow)
{
_workflow = workflow;
}
public bool CanExecute(object? parameter) => _workflow.CanStart;
public async void Execute(object? parameter)
{
await _workflow.StartAsync(CancellationToken.None);
}
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged() =>
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}In modern code, teams often use RelayCommand/AsyncRelayCommand from MVVM toolkits rather than hand-writing this every time.
Why it is useful
- clean UI binding
- separates user intent from execution logic
- supports enable/disable semantics
- can centralize authorization, validation, and state gating
When it is overkill
Outside UI or queued-operation scenarios, turning every method into a Command object can be excessive.
Also, WPF commands can become thin wrappers that add little value if the real logic is still tangled in the ViewModel.
7. Decorator
The real problem it solves
You want to add behavior around an existing service without modifying its core implementation.
Examples:
- logging
- retries
- timeout enforcement
- metrics
- caching
- authorization checks
- tracing
Decorator is one of the most practical patterns in modern .NET.
How it appears in .NET systems
Either manually or via DI pipeline patterns.
Example:
public interface IMotionService
{
Task MoveToAsync(Position position, CancellationToken cancellationToken);
}
public sealed class MotionService : IMotionService
{
private readonly IMachineController _controller;
public MotionService(IMachineController controller)
{
_controller = controller;
}
public Task MoveToAsync(Position position, CancellationToken cancellationToken) =>
_controller.MoveToAsync(position, cancellationToken);
}
public sealed class LoggingMotionServiceDecorator : IMotionService
{
private readonly IMotionService _inner;
private readonly ILogger<LoggingMotionServiceDecorator> _logger;
public LoggingMotionServiceDecorator(IMotionService inner, ILogger<LoggingMotionServiceDecorator> logger)
{
_inner = inner;
_logger = logger;
}
public async Task MoveToAsync(Position position, CancellationToken cancellationToken)
{
_logger.LogInformation("Moving to X={X}, Y={Y}", position.X, position.Y);
try
{
await _inner.MoveToAsync(position, cancellationToken);
_logger.LogInformation("Move completed.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Move failed.");
throw;
}
}
}Why it is useful
- preserves single responsibility
- adds cross-cutting behavior cleanly
- avoids contaminating business logic with logging/retry code
- supports composable pipelines
When it is overkill
- when wrappers stack so deeply that tracing call flow is painful
- when each decorator adds tiny value but lots of indirection
- when debugging becomes “which layer threw this?”
Decorator is powerful, but too many wrappers can make simple behavior feel mysterious.
8. Repository
The real problem it solves
At its best, Repository hides persistence details and presents a collection-like access model aligned with the domain.
Examples:
- loading machine runs
- saving inspection results
- querying defect history
- retrieving recipes
But Repository is also one of the most overused patterns in .NET.
How it appears in .NET systems
public interface IInspectionRunRepository
{
Task AddAsync(InspectionRun run, CancellationToken cancellationToken);
Task<InspectionRun?> GetByIdAsync(Guid runId, CancellationToken cancellationToken);
Task<IReadOnlyList<InspectionRun>> GetRecentAsync(int count, CancellationToken cancellationToken);
}Why it is useful
- isolates persistence from core logic
- improves testability if used correctly
- can express domain-centric queries
- protects application layer from ORM details
Trade-offs and when it hurts
Repository becomes weak when:
- it is just a thin CRUD wrapper over Entity Framework with no real abstraction value
- it hides useful query features
- it duplicates ORM capabilities badly
- generic repositories distort domain language
- it creates many tiny methods that nobody can find or maintain
In many modern .NET apps, EF Core already acts like a repository/unit of work combination. Adding another generic repository on top can add no real value.
Repository is useful when:
- domain access patterns matter
- persistence source may vary meaningfully
- you need a stable application boundary
- you want to prevent query logic from leaking everywhere
Repository is overkill when it just becomes ceremony over DbContext.
9. Mediator / message-based coordination
The real problem it solves
Too many components talk to each other directly, creating a dependency web.
Examples:
- UI issues command → workflow service → audit logger → notification service → persistence → machine control
- one action fans out to multiple reactions
- business operations need orchestration without every component knowing every other
Mediator centralizes coordination or at least reduces direct coupling.
How it appears in .NET systems
Often through libraries like MediatR, but the concept matters more than the library.
Examples:
- request/response commands and queries
- domain notifications
- internal workflow events
Why it is useful
- reduces direct dependencies
- clarifies input/output contract of requests
- supports pipeline behaviors
- can simplify test setup in some cases
When it is overkill
- when simple direct service calls would be clearer
- when every call goes through a mediator “because architecture”
- when business flow becomes hidden behind handlers distributed across the codebase
- when tracing the actual execution path becomes difficult
Mediator can either simplify coordination or obscure it. It depends on how far you push it.
10. Template Method vs composition-based alternatives
The real problem it solves
You have a common workflow skeleton with a few varying steps.
Examples:
- inspection pipeline with fixed phases but machine-specific steps
- file export process with shared validation and finalization
- machine startup sequence with vendor-specific commands
Template Method traditionally solves this with inheritance:
- base class defines algorithm structure
- subclasses override steps
Why it is less attractive now
In modern .NET, composition often beats inheritance because inheritance tends to:
- create fragile base classes
- hide variation through override chains
- couple unrelated behavior
- make testing and evolution harder
Composition alternative:
public interface IAlignmentStep
{
Task ExecuteAsync(InspectionContext context, CancellationToken cancellationToken);
}
public sealed class InspectionWorkflow
{
private readonly IReadOnlyList<IAlignmentStep> _steps;
public InspectionWorkflow(IReadOnlyList<IAlignmentStep> steps)
{
_steps = steps;
}
public async Task RunAsync(InspectionContext context, CancellationToken cancellationToken)
{
foreach (var step in _steps)
{
await step.ExecuteAsync(context, cancellationToken);
}
}
}When Template Method still helps
- stable algorithm skeleton
- small number of variants
- controlled inheritance boundary
- framework-level extension points
But in business and machine logic, composition usually scales better.
Part 3 — Real problems in a wafer inspection WPF system
Now let’s ground this in the actual system:
“A WPF desktop app controlling a wafer inspection machine”
This system naturally attracts certain patterns because of its pressures.
Adapter for vendor SDK integration
This is almost unavoidable.
Vendor SDKs are often:
- synchronous
- stateful
- callback-heavy
- poorly documented
- inconsistent across models
- full of raw codes and structures
You do not want VendorSdkAxisController, VendorAlarmCode, or raw callback signatures spread through ViewModels and application services.
So you build adapters such as:
IMotionControllerICameraControllerILightControllerIWaferLoaderIAlarmReader
This keeps hardware detail at the boundary.
Strategy for inspection or classification variations
Different products, customers, or machine modes often require different behavior:
- different preprocessing
- different thresholding logic
- different classification rules
- different defect filters
- different output schemas
You want the overall workflow to remain stable while the variable logic changes cleanly. That is Strategy.
State pattern / state machine for workflow and machine state
This is one of the most important structures in the system.
Because the machine cannot safely do everything at all times.
Examples:
- cannot start run when not homed
- cannot load wafer while stage moving
- cannot clear alarm unless safe state reached
- cannot export results until inspection finalized
If you do not model state explicitly, invalid operations leak in and the UI becomes inconsistent with actual machine behavior.
Command pattern in WPF
This appears directly in:
- Start
- Stop
- Pause
- Resume
- Load recipe
- Acknowledge alarm
- Reset machine
The UI binds to commands, and CanExecute reflects machine or workflow state.
This is much better than putting button click logic directly in code-behind.
Observer / event-based result streaming
Inspection systems are event-rich:
- frame captured
- defect found
- batch progress updated
- health metric changed
- alarm raised
- result persisted
These events often feed:
- live UI
- historian
- result storage
- trace logging
- notifications
- monitoring dashboards
A producer-consumer or pub-sub model is a natural fit.
Factory for machine-specific components
Often the same application supports:
- simulator mode
- different hardware revisions
- optional subsystems
- different camera vendors
- different deployment configurations
A factory or composition root chooses the right module set.
Decorator for resilience around machine services
You often want wrappers around core services for:
- logging every machine operation
- timeout around hardware commands
- retry for transient communication errors
- metrics/tracing
- operator audit logging
You do not want every service method full of repeated try/catch/retry/logging code.
That is where Decorator helps a lot.
Part 4 — How we use these patterns in .NET practically
Let’s connect the patterns into a realistic structure.
Example architecture shape
You might have layers like:
- UI layer: WPF views, ViewModels, commands
- Application layer: workflows, orchestration, use cases
- Domain/application contracts: machine abstractions, result contracts, policies
- Infrastructure layer: vendor SDK adapters, persistence, file IO, transport
- Cross-cutting: logging decorators, retry policies, metrics, tracing
Example: prevent hardware details from leaking
Bad version:
public sealed class MainViewModel
{
private readonly VendorMachineSdk _sdk;
public MainViewModel(VendorMachineSdk sdk)
{
_sdk = sdk;
}
public void StartInspection()
{
_sdk.InitializeAxis();
_sdk.SetMode(4);
_sdk.ExecuteRecipe("default.rcp");
}
}Problems:
- UI knows vendor API
- impossible to test meaningfully
- vendor semantics leak upward
- hard to change machine model
- no clean place for retries, logging, timeout, translation
Better version:
public interface IInspectionWorkflow
{
Task StartAsync(CancellationToken cancellationToken);
}
public sealed class InspectionWorkflow : IInspectionWorkflow
{
private readonly IMachineController _machineController;
private readonly IRecipeLoader _recipeLoader;
private readonly IInspectionStrategyResolver _strategyResolver;
private readonly IInspectionContextProvider _contextProvider;
public InspectionWorkflow(
IMachineController machineController,
IRecipeLoader recipeLoader,
IInspectionStrategyResolver strategyResolver,
IInspectionContextProvider contextProvider)
{
_machineController = machineController;
_recipeLoader = recipeLoader;
_strategyResolver = strategyResolver;
_contextProvider = contextProvider;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var context = await _contextProvider.CreateAsync(cancellationToken);
await _machineController.EnsureReadyAsync(cancellationToken);
await _recipeLoader.LoadAsync(context.RecipeName, cancellationToken);
var strategy = _strategyResolver.Resolve(context.Mode);
var result = strategy.Inspect(context);
await _machineController.CommitInspectionAsync(result, cancellationToken);
}
}UI then binds to that workflow through a command.
Example: DI integration
services.AddSingleton<VendorSdkClient>();
services.AddSingleton<IMachineController, VendorMachineControllerAdapter>();
services.AddSingleton<IRecipeLoader, VendorRecipeLoaderAdapter>();
services.AddScoped<BrightFieldInspectionStrategy>();
services.AddScoped<DarkFieldInspectionStrategy>();
services.AddScoped<IInspectionStrategyResolver, InspectionStrategyResolver>();
services.AddScoped<IInspectionWorkflow, InspectionWorkflow>();If decorating manually:
services.AddScoped<MotionService>();
services.AddScoped<IMotionService>(sp =>
{
var inner = sp.GetRequiredService<MotionService>();
var logger = sp.GetRequiredService<ILogger<LoggingMotionServiceDecorator>>();
return new LoggingMotionServiceDecorator(inner, logger);
});How patterns help testability
Patterns help testability when they create meaningful seams.
For example:
- Adapter lets you replace hardware with fake implementations
- Strategy lets you test each behavior independently
- State logic can be tested as pure transition rules
- Command logic can be tested through ViewModel state and command enablement
- Decorator can be tested separately from the core service
Example test seam:
public sealed class FakeMachineController : IMachineController
{
public bool EnsureReadyCalled { get; private set; }
public Task ConnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task EnsureReadyAsync(CancellationToken cancellationToken)
{
EnsureReadyCalled = true;
return Task.CompletedTask;
}
public Task CommitInspectionAsync(InspectionResult result, CancellationToken cancellationToken)
=> Task.CompletedTask;
}Now the workflow can be tested without real hardware.
How patterns help extensibility
Suppose a new machine model comes in.
With good boundaries:
- add a new adapter
- register a different module through factory/composition
- maybe add new strategy implementations
- keep most orchestration unchanged
Without boundaries:
- vendor-specific branches spread across UI, services, and persistence
That is where patterns pay off.
Part 5 — Common mistakes
Forcing a pattern where a simple class would be enough
This is extremely common.
A team sees future variation everywhere and builds abstractions for everything.
Result:
- 12 files for a behavior that could be one class
- interface plus implementation plus factory plus resolver for stable logic
- more jumping around code than understanding it
Production consequence:
- slower onboarding
- slower debugging
- harder refactoring
- perceived architecture “cleanliness” but actual lower productivity
Over-engineering with too many layers and abstractions
You can easily end up with:
UI Command → ViewModel service → application service → mediator request → handler → domain service → repository → ORM
for something simple like acknowledging an alarm.
Every layer adds:
- indirection
- stack depth
- debugging cost
- cognitive load
Layers should exist only where they add a real boundary or responsibility separation.
Using Repository blindly where it adds little value
This is a classic .NET mistake.
If your repository is:
Task<TEntity> GetByIdAsync(Guid id);
Task AddAsync(TEntity entity);
Task UpdateAsync(TEntity entity);
Task DeleteAsync(Guid id);for every entity, with almost no domain meaning, it may just be empty ceremony.
Production consequence:
- duplicated query logic
- poor use of EF Core capabilities
- awkward abstractions
- less readable application code
Building complex inheritance trees instead of composition
Inheritance looks elegant early and painful later.
Typical issues:
- base class grows too many hooks
- subclasses override behavior unpredictably
- shared state becomes fragile
- testing a subclass requires understanding hidden base behavior
Production consequence:
- “safe” changes become risky
- bugs appear from override interactions
- debugging requires reading parent-parent-parent class hierarchy
Confusing pattern names with design quality
Some code reviews sound impressive:
- “We use mediator, strategy, repository, decorator, and factory.”
That means nothing by itself.
Bad code can use many patterns. Good code can use very few.
Pattern vocabulary is not architecture quality. Clarity, changeability, correctness, and operational behavior are.
Turning patterns into ceremony
This is when a pattern stops being a helpful structure and becomes ritual.
Examples:
- every action must be a handler
- every dependency must have an interface
- every entity must have a repository
- every workflow must be abstracted even if only one variant exists
Ceremony creates distance between the developer and the problem.
Part 6 — Trade-offs
Flexibility vs complexity
Patterns often buy flexibility by adding indirection.
That is fine when the variation is real.
It is wasteful when the variation is hypothetical.
Question to ask: What change are we actually preparing for?
If you cannot name a likely change, the flexibility may be imaginary.
Reuse vs readability
A pattern can help reuse, but excessive reuse can make code harder to follow.
Sometimes duplication is cheaper than a deep abstraction.
Especially in machine workflows, code that reads clearly like the physical process is often better than an overly generalized reusable engine.
Abstraction vs debugging difficulty
Abstractions hide details. That is their job.
But debugging production issues often requires seeing details.
Too much abstraction can make it hard to answer:
- what actually called the device?
- where did the timeout come from?
- which handler changed the state?
- which subscriber reacted to this event?
Senior engineers balance clean abstraction with observable execution.
Framework-driven patterns vs domain-driven patterns
Some patterns are pushed by frameworks:
- WPF Command
- DI-driven strategy/decorator composition
- mediator libraries
Some patterns arise from domain pressure:
- explicit machine state model
- adapter around hardware SDK
- strategy for inspection modes
The strongest designs are domain-driven first. Framework patterns should support the domain, not dictate it.
Part 7 — How to choose a pattern
Recognize the underlying problem first
Do not start with the pattern. Start with the pain.
Ask:
- What is hard to change?
- What keeps duplicating?
- What dependency leaks everywhere?
- What behavior varies by mode or type?
- What logic becomes invalid in some states?
- What cross-cutting concern keeps repeating?
That usually points naturally to a pattern.
Decide whether the pattern is justified
A pattern is justified when it reduces total system complexity over time.
Good signs:
- recurring variation
- unstable external dependency
- repeated cross-cutting behavior
- invalid state transitions causing bugs
- clear fan-out event model
- repeated creation logic by runtime context
When a simpler solution is better
Use the simplest design that still protects the system.
Sometimes that means:
- one class
- one enum with explicit transitions
- one small conditional
- one direct service call
- one private helper instead of a factory
Senior engineers are not afraid of simple code.
Patterns often emerge from refactoring
This is a key leadership point.
The healthiest patterns often appear after the code shows a real need.
Example:
- first version has one inspection flow
- later two variants appear
- conditional grows
- duplication appears
- then Strategy emerges naturally
That is better than inventing a pattern for ten hypothetical future modes.
Part 8 — Design patterns vs modern .NET features
Modern .NET changes how patterns look.
DI reduces the need for some classic patterns
Constructor injection and service registration already solve parts of:
- factory management
- service location
- dependency assembly
You often do not need elaborate object-creation patterns when DI can compose the graph.
Still, DI does not remove the need for:
- runtime variant selection
- boundary isolation
- explicit workflow/state modeling
Delegates and lambdas simplify Strategy-like cases
Some strategies do not need classes.
Example:
Func<InspectionContext, InspectionResult> classify = context =>
{
// simple classification rule
return new InspectionResult();
};For small, local variation, this may be enough. For richer behavior, lifecycle, dependencies, or testability needs, a full Strategy class may still be better.
async/await changes observer and command usage
In modern .NET, asynchronous workflows matter everywhere.
Patterns must adapt:
- commands often need async-aware implementations
- observers may need channels or async streams
- decorators must preserve cancellation and timeout semantics
- adapters often need to bridge blocking SDKs carefully
Records and modern language features reduce boilerplate
Records, pattern matching, and better switch expressions make some designs cleaner and less ceremonious.
For example, state-related decision logic can sometimes be expressed much more clearly now than older OO-heavy code.
Which classic patterns are still highly relevant
Still highly relevant:
- Adapter
- Strategy
- State
- Command
- Decorator
- Factory in contextual creation scenarios
- Observer / event-driven coordination
Less explicit now:
- Abstract Factory in textbook form
- Template Method as a default extension mechanism
- Singleton as a pattern discussion topic, since DI/service lifetimes usually cover that concern more cleanly
The pattern may still exist conceptually, but modern .NET often expresses it differently.
Part 9 — Code review and design thinking
What a senior engineer looks for
When reviewing pattern usage, a senior engineer asks:
- What problem is this abstraction solving?
- Is the variation real or imagined?
- Does this structure make change easier?
- Does it improve or worsen debugging?
- Is the boundary aligned with the domain?
- Is the code more understandable because of this pattern?
They are not impressed by pattern density. They care about whether the shape of the code matches the pressure of the system.
How to spot when a pattern is helping
A pattern is helping when:
- related variation is localized
- new behavior can be added without editing many existing places
- tests become simpler
- vendor details stay at boundaries
- invalid states become harder to represent
- cross-cutting behavior is centralized cleanly
How to spot when a pattern is hiding complexity
Warning signs:
- too many tiny classes with weak names
- call flow hard to trace
- one business action spread across many handlers/wrappers
- “indirection for purity” instead of concrete value
- abstractions that mirror implementation instead of domain concepts
How to challenge unnecessary abstractions constructively
In review, avoid saying:
“This pattern is wrong.”
Better:
- “What change are we expecting this abstraction to support?”
- “Would a simpler class be easier to understand here?”
- “Is this indirection paying for itself yet?”
- “Could we wait until we have a second variation before extracting this?”
- “Does this boundary reflect the domain, or just the framework?”
That is how senior engineers guide design without being dogmatic.
Part 10 — Senior engineer mental model
Experienced engineers do not treat patterns as goals. They treat them as tools.
Their mindset is usually:
1. Start from the pressure, not the catalog
They look for:
- unstable dependencies
- growing variation
- repeated workflows
- invalid state risk
- cross-cutting duplication
- fan-out event needs
Then they choose a structure that fits.
2. Use patterns to reduce risk and improve clarity
The real goal is not elegance. The goal is safer change, fewer production bugs, easier testing, and clearer ownership.
3. Let patterns emerge when the codebase needs them
Strong teams refactor toward patterns.
They do not build an architecture museum on day one.
4. Prefer domain clarity over pattern purity
A state machine that clearly models machine readiness is better than a perfectly “textbook” pattern that nobody can follow.
A thin adapter around ugly vendor SDK code is better than leaking the SDK everywhere.
A simple strategy resolver is better than a grand plugin framework no one needs.
5. Explain pattern choices in terms of trade-offs
In an interview, the best answers sound like this:
“I would use an Adapter around the vendor SDK because that boundary is unstable and I want to prevent hardware-specific types and failure semantics from leaking into the application layer.”
“I would use Strategy only where inspection behavior really varies by mode or recipe. I would not abstract everything up front.”
“For machine and workflow behavior, I care more about an explicit state model than about pattern purity, because invalid transitions are a major production risk.”
“I use Decorator a lot for logging, timeout, retry, and metrics because those concerns repeat and I do not want them mixed into the core machine logic.”
That is the voice of someone who has operated real systems.
Final summary
In modern .NET systems, design patterns matter most when they solve real pressure:
- Adapter isolates ugly or unstable external dependencies
- Strategy handles controlled behavioral variation
- Factory centralizes meaningful runtime creation decisions
- Observer / Pub-Sub supports event-driven flows and streaming
- State protects workflows with valid transitions
- Command cleanly connects WPF UI intent to application behavior
- Decorator adds logging, retry, timeout, metrics, and resilience cleanly
- Repository can help, but is often overused
- Mediator can reduce coupling, but can also hide flow
- Template Method is less attractive now than composition in many cases
The key lesson is this:
Patterns are not signs of good design. They are tools that may help produce good design when applied to the right problem, at the right time, with the right level of simplicity.
For a production WPF machine-control system, the best pattern usage is the one that:
- keeps hardware boundaries clean
- makes workflows explicit
- prevents invalid operations
- supports observability
- stays testable
- remains understandable under production pressure
That is how a senior engineer should think about patterns.
If you want, I can turn this into a second version focused on interview Q&A style answers with likely questions and strong sample responses.