Part 1 — Big picture
Modern C# matters in real systems for one simple reason: large codebases spend far more time being read, debugged, extended, and reviewed than being written. Newer language features are useful when they make the shape of the code closer to the shape of the problem. Pattern matching can make state logic easier to read. Records can make message-like data safer and simpler. Nullable reference types can make API contracts more honest. But none of that is valuable just because the syntax is newer; it is valuable only when it reduces accidental complexity. Microsoft’s own evolution of the language reflects this direction: recent versions have added stronger pattern matching, records, nullable reference types, primary constructors, and collection expressions to improve expressiveness and safety, not just terseness. (Microsoft Learn)
In production systems, “modern” should never mean “showing off the newest syntax.” It should mean writing code that a teammate can understand six months later during an outage. In a wafer inspection desktop app, that usually means the boring questions matter more than novelty: Can I see the state transition rules quickly? Can I trust that null was handled intentionally? Can I tell whether this type represents mutable behavior or immutable data? Can I step through it in the debugger without deciphering cleverness first? Coding conventions matter here because consistency is often more valuable than local elegance. (Microsoft Learn)
That is the mental model I would use in an interview: modern C# is good when it helps clarity, correctness, and maintainability; bad when it compresses meaning so much that only the author can still follow it.
Part 2 — Pattern matching in practice
Pattern matching is one of the most genuinely useful modern C# features in real systems. It is especially good when you are classifying inputs by shape, type, or state and returning a result. Microsoft’s current docs describe pattern matching as a way to test an expression against characteristics using is, switch, and switch expression, with support for type, property, relational, logical, list, discard, and other patterns. That is exactly the kind of logic you see in machine events, alarm categorization, and workflow state handling. (Microsoft Learn)
A very normal pre-modern version of machine event handling often looks like this:
public DomainEvent Map(VendorEvent evt)
{
if (evt == null) throw new ArgumentNullException(nameof(evt));
if (evt.Type == VendorEventType.AxisFault)
{
var axis = (AxisFaultEvent)evt;
if (axis.Code >= 5000)
return new CriticalAlarmRaised(axis.AxisId, axis.Code, axis.Message);
return new WarningAlarmRaised(axis.AxisId, axis.Code, axis.Message);
}
if (evt.Type == VendorEventType.ImageCaptured)
{
var image = (ImageCapturedEvent)evt;
return new FrameCaptured(image.CameraId, image.FrameId, image.TimestampUtc);
}
if (evt.Type == VendorEventType.RunCompleted)
{
var run = (RunCompletedEvent)evt;
return new InspectionRunCompleted(run.RunId, run.TotalParts, run.TotalDefects);
}
throw new NotSupportedException($"Unknown vendor event type: {evt.Type}");
}This is not terrible, but it mixes classification, casting, and branching in a way that becomes noisy as the event model grows.
Pattern matching usually improves this:
public DomainEvent Map(VendorEvent evt) =>
evt switch
{
AxisFaultEvent { Code: >= 5000 } axis =>
new CriticalAlarmRaised(axis.AxisId, axis.Code, axis.Message),
AxisFaultEvent axis =>
new WarningAlarmRaised(axis.AxisId, axis.Code, axis.Message),
ImageCapturedEvent image =>
new FrameCaptured(image.CameraId, image.FrameId, image.TimestampUtc),
RunCompletedEvent run =>
new InspectionRunCompleted(run.RunId, run.TotalParts, run.TotalDefects),
null =>
throw new ArgumentNullException(nameof(evt)),
_ =>
throw new NotSupportedException($"Unknown vendor event type: {evt.GetType().Name}")
};This is better because the classification logic is now the structure of the method. You can scan it top to bottom and see the mapping rules immediately.
Property patterns are excellent when the decision depends on the shape of a state object rather than its exact type:
public static bool CanStartInspection(MachineState state) =>
state is
{
Connection: { IsConnected: true },
Safety: { EStopActive: false, DoorOpen: false },
Motion: { IsHomed: true },
Recipe: not null,
CurrentRun: null
};That is a great fit for guard logic. It reads like a checklist.
Relational and logical patterns are useful when classifying thresholds:
public static AlarmSeverity Classify(Alarm alarm) =>
alarm switch
{
{ Code: >= 9000 } => AlarmSeverity.Fatal,
{ Code: >= 5000 and < 9000 } => AlarmSeverity.Error,
{ Code: >= 1000 and < 5000 } => AlarmSeverity.Warning,
_ => AlarmSeverity.Info
};Where people get into trouble is when pattern matching becomes a puzzle. For example, deeply nested switch expressions with multiple when clauses, tuple patterns, and clever guards can become harder to debug than plain if statements. My rule is simple: if the reader must mentally execute the syntax to understand it, you went too far. Pattern matching is best for classification logic, not for hiding business complexity.
A good senior-engineer review comment is often: “Nice use of patterns, but this rule set is now important enough that it deserves named intermediate concepts.” In practice, that might mean extracting IsMachineReadyForAutoRun, IsRecoverableAlarm, or MapVendorFaultToDomainAlarm rather than stacking everything into one giant switch.
Part 3 — Records, immutability, and data flow
Records are one of the best additions to modern C# when used for the right kind of type. Microsoft documents records as types designed to encapsulate data, with built-in support for value-based equality; record classes are reference types, and record structs are value types. (Microsoft Learn)
That makes records a strong fit for message-like data:
- machine events
- workflow commands
- snapshots
- DTOs
- result payloads
- immutable UI read models
For example:
public sealed record DefectDetected(
string RunId,
string WaferId,
string CameraId,
string DefectCode,
double X,
double Y,
DateTime TimestampUtc,
string? ThumbnailPath);This is a good record because it is just data moving through the system.
Value-based equality is genuinely helpful for some scenarios. If two workflow messages have the same data, they compare as equal without manual boilerplate. That is useful in tests, caching keys, deduplication logic, and snapshot comparisons. init-only properties also help preserve a mostly immutable model after construction, which is often what you want for configuration, results, and messages. Records also support nondestructive mutation via with, which is handy for state snapshots and UI read-model refreshes. (Microsoft Learn)
Example:
public sealed record InspectionRunView(
string RunId,
int TotalParts,
int TotalDefects,
bool IsCompleted,
string? StatusText);
var updated = currentView with
{
TotalDefects = currentView.TotalDefects + 1,
StatusText = "Defect detected"
};This is clean for immutable state projection.
But records are not “better classes.” They are better for data-centric types. They are usually a bad fit for:
- entities with identity and lifecycle
- mutable ViewModels
- service objects
- resource-owning objects
- types where equality should be identity-based, not value-based
A CameraConnection, AxisController, or InspectionSession is almost always better as a normal class. Those types have behavior, ownership, lifetime, and often mutable state. Treating them as records can create misleading equality semantics and encourage the wrong mental model.
A good heuristic is this: if the type answers “what happened?” or “what data are we passing?”, a record is often a good fit. If it answers “who owns this behavior?” or “what object changes over time?”, a class is usually better.
Part 4 — Delegates, Func/Action, and lightweight behavior injection
Delegates are underrated in production code. Many teams either overuse interfaces for tiny behaviors or avoid delegates because they feel “too functional.” The pragmatic middle ground is very powerful.
Delegates are useful when the behavior is small, local, and unlikely to grow into a rich abstraction. For example, retry wrappers, timeout wrappers, validation steps, and small pipeline hooks are good delegate candidates.
A nice example is a machine command wrapper:
public async Task<CommandResult> ExecuteWithPolicies(
string operationName,
Func<CancellationToken, Task<CommandResult>> action,
CancellationToken cancellationToken)
{
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(_options.CommandTimeout);
_logger.LogInformation("Starting operation {Operation}", operationName);
var result = await action(timeoutCts.Token);
_logger.LogInformation("Completed operation {Operation} with status {Status}",
operationName, result.Status);
return result;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return CommandResult.Timeout(operationName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Operation {Operation} failed", operationName);
return CommandResult.Failed(operationName, ex.Message);
}
}This is better than creating ICommandExecutionBehavior, IWrappedOperation, and three tiny strategy classes if the only variability is the operation body.
Delegates are also useful for configurable validation flows:
public sealed class RecipeValidator
{
private readonly IReadOnlyList<Func<Recipe, ValidationIssue?>> _checks;
public RecipeValidator(IReadOnlyList<Func<Recipe, ValidationIssue?>> checks)
{
_checks = checks;
}
public IReadOnlyList<ValidationIssue> Validate(Recipe recipe)
{
var issues = new List<ValidationIssue>();
foreach (var check in _checks)
{
var issue = check(recipe);
if (issue is not null)
issues.Add(issue);
}
return issues;
}
}That is perfectly reasonable when each check is simple and stateless.
But delegates stop being a good fit when the behavior has a meaningful name, multiple dependencies, lifecycle, configuration, or several related methods. At that point, explicit types are clearer. An IInspectionStep, IAlarmPolicy, or IRetryStrategy becomes easier to reason about than passing around nested Funcs.
The trade-off is the usual one: delegates reduce boilerplate, but interfaces improve discoverability. Use delegates for lightweight behavior injection. Use explicit abstractions when the behavior is part of the domain model or architecture.
Part 5 — Nullability and safer API design
Nullable reference types are one of the most important practical features in modern C#. Microsoft describes them as a feature set intended to reduce NullReferenceException by making nullability explicit and enabling compiler analysis. (Microsoft Learn)
In real systems, this matters because many bugs are not “null bugs” in the shallow sense. They are contract bugs. Someone did not know whether a value was required, optional, late-bound, unavailable, or invalid. Nullability forces that conversation into the type system.
Bad contract:
public Task<ImageFrame> TryGetLatestFrameAsync(string cameraId, CancellationToken ct);Does this always return a frame? What if the camera is disconnected? What if no frame exists yet?
Better contract if absence is expected:
public Task<ImageFrame?> TryGetLatestFrameAsync(string cameraId, CancellationToken ct);Better still if the caller needs a reason:
public Task<Result<ImageFrame>> GetLatestFrameAsync(string cameraId, CancellationToken ct);That is the pragmatic rule:
- use
nullwhen absence is normal and unsurprising - use
Resultor similar when the caller needs meaning, not just absence
Examples:
- optional thumbnail path:
string? - optional operator note:
string? - optional machine metadata from vendor SDK:
MachineMetadata? - operation outcome with failure reason:
Result<T> - missing workflow prerequisite with domain meaning:
Resultor domain error type, not plainnull
Nullability also improves ViewModel code when used honestly:
public sealed class InspectionViewModel : ObservableObject
{
public InspectionRunView? CurrentRun { get; private set; }
public ImageSource? SelectedThumbnail { get; private set; }
public string StatusText { get; private set; } = "Idle";
}This communicates that some UI state is genuinely absent sometimes.
What experienced engineers do not do is fight the compiler with ! everywhere. If you keep writing the null-forgiving operator, the compiler is telling you the design contract is unclear. Usually the fix is one of these:
- initialize earlier
- make the property nullable
- split object states
- use a constructor requirement
- model “not available yet” explicitly
In other words, nullable warnings are often design feedback, not noise.
Part 6 — Modern object and construction features
Target-typed new, object initializers, required members, collection expressions, and primary constructors can all improve code, but they are very easy to misuse. Microsoft documents collection expressions and primary constructors as newer language features added in C# 12, and the current version history tracks the growing set of language improvements over time. (Microsoft Learn)
Target-typed new
Good:
private readonly Dictionary<string, AxisState> _axes = new();
private readonly SemaphoreSlim _gate = new(1, 1);This works because the type is obvious from the left side.
Less good:
var config = new(...);If I cannot immediately infer the type, the brevity hurts readability.
Object initializers
Great for simple data setup:
var summary = new RunSummary
{
RunId = runId,
StartedAtUtc = clock.UtcNow,
RecipeName = recipe.Name,
OperatorId = operatorId
};Not great when invariants matter. If the object must always be valid, constructor-based creation is often clearer.
required members
Useful for configuration-style objects when you want construction-time completeness without huge constructors:
public sealed class MachineOptions
{
public required string MachineId { get; init; }
public required string PlcAddress { get; init; }
public required TimeSpan CommandTimeout { get; init; }
public string? CameraEndpoint { get; init; }
}This is a good fit for app options and immutable settings. It is less attractive for domain objects with richer invariants, where a constructor or factory may still be clearer.
Primary constructors
Primary constructors can reduce boilerplate, but I would use them selectively in large codebases. They are nice when the class is simple and the constructor parameters are clearly part of the type’s shape. They are not automatically a win for every service.
Reasonable:
public sealed class AlarmFormatter(ILocalizationService localization)
{
public string Format(Alarm alarm) =>
localization.Translate(alarm.Code, alarm.Message);
}Less appealing when the service has many dependencies, many fields, or complex initialization. In those cases, the classic explicit constructor is usually easier to scan in a mature team codebase.
Collection expressions
Collection expressions can make simple initialization cleaner:
string[] allowedStates = ["Idle", "Ready", "Running"];
List<int> warningCodes = [1001, 1002, 1005];I would use them mostly for straightforward literals. Once spreads, conversions, or non-obvious target types enter the picture, readability can drop. Even the feature specification discusses translation and optimization behavior in detail, which is a reminder that concise syntax may hide nontrivial semantics. (Microsoft Learn)
The general rule for construction features is simple: shorter is better only when the object’s creation story is still obvious.
Part 7 — Expression-bodied members, local functions, and small-scale code shaping
Expression-bodied members are good when the body is truly a single obvious expression.
Good:
public bool IsConnected => _connection.State == ConnectionState.Connected;
public override string ToString() => $"{MachineId} ({Status})";Not good:
public CommandResult Execute() =>
_machine is null
? CommandResult.Failed("No machine")
: !_machine.IsReady
? CommandResult.Failed("Machine not ready")
: _machine.TryExecute(_command, out var error)
? CommandResult.Success()
: CommandResult.Failed(error);That is technically compact but worse to debug and review.
Local functions are more interesting. They are excellent when a method has a main flow plus a few helper routines that are meaningful only inside that method. This helps keep orchestration readable without polluting the class with private one-off helpers.
public async Task<Result<RunSummary>> CompleteRunAsync(
InspectionRun run,
CancellationToken ct)
{
Validate(run);
var persisted = await PersistAsync(run, ct);
if (!persisted.IsSuccess)
return Result<RunSummary>.Failure(persisted.Error);
var summary = BuildSummary(run);
await PublishEventsAsync(run, summary, ct);
return Result<RunSummary>.Success(summary);
void Validate(InspectionRun candidate)
{
if (!candidate.IsCompleted)
throw new InvalidOperationException("Run is not completed.");
if (candidate.TotalParts <= 0)
throw new InvalidOperationException("Run has no inspected parts.");
}
RunSummary BuildSummary(InspectionRun candidate) =>
new(candidate.RunId, candidate.TotalParts, candidate.TotalDefects, candidate.CompletedAtUtc);
}This is a good use of local functions because the helpers support the main method and are not reusable elsewhere.
The key is moderation. Local functions are a structuring tool, not a hiding tool.
Part 8 — Collection, LINQ, and modern syntax trade-offs
LINQ is great when the query is the main idea and performance pressure is modest. It is especially good in reporting, UI projections, grouping, summarization, and test code.
For example, computing a dashboard summary:
var summary = defects
.Where(d => d.Severity >= Severity.Warning)
.GroupBy(d => d.DefectCode)
.Select(g => new DefectGroupView(
g.Key,
g.Count(),
g.MaxBy(d => d.Confidence)?.Confidence ?? 0))
.OrderByDescending(x => x.Count)
.ToList();That is readable because it expresses a query.
But in hot paths, such as high-frequency image result ingestion or per-frame processing, LINQ can hide allocations, multiple enumeration, and control flow. The problem is not ideology. The problem is system pressure. When you process thousands of results per second, explicit loops are often easier to optimize and easier to reason about.
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var defect in defects)
{
if (defect.Severity < Severity.Warning)
continue;
counts.TryGetValue(defect.DefectCode, out var current);
counts[defect.DefectCode] = current + 1;
}This is less elegant, but in a hot path it may be the right choice.
A mature team does not say “always use LINQ” or “never use LINQ.” It asks:
- Is this on a hot path?
- Does the query express the business logic clearly?
- Does it allocate meaningfully?
- Is debugging step-by-step important here?
- Are we enumerating more than once?
That is the right trade-off frame.
Part 9 — Modern C# in state, result, and workflow code
This is where modern C# becomes genuinely powerful in a WPF desktop app controlling a wafer inspection machine.
You usually have several things happening at once:
- machine state changes
- operator actions
- workflow messages
- defect processing
- alarm transitions
- UI projections
Modern C# helps when you separate behavior from data clearly.
For example, records for immutable workflow messages:
public abstract record WorkflowMessage(DateTime TimestampUtc);
public sealed record StartInspectionRequested(
string RunId,
string RecipeId,
string OperatorId,
DateTime TimestampUtc) : WorkflowMessage(TimestampUtc);
public sealed record InspectionCompleted(
string RunId,
int TotalDies,
int TotalDefects,
DateTime TimestampUtc) : WorkflowMessage(TimestampUtc);
public sealed record InspectionFailed(
string RunId,
string ErrorCode,
string ErrorMessage,
DateTime TimestampUtc) : WorkflowMessage(TimestampUtc);Pattern matching for state transitions:
public static MachineWorkflowState Transition(
MachineWorkflowState current,
WorkflowMessage message) =>
(current, message) switch
{
(IdleState, StartInspectionRequested start) =>
new RunningState(start.RunId, start.RecipeId, startedAtUtc: start.TimestampUtc),
(RunningState running, InspectionCompleted completed)
when running.RunId == completed.RunId =>
new CompletedState(completed.RunId, completed.TotalDies, completed.TotalDefects),
(RunningState running, InspectionFailed failed)
when running.RunId == failed.RunId =>
new FailedState(failed.RunId, failed.ErrorCode, failed.ErrorMessage),
_ =>
throw new InvalidOperationException(
$"Invalid transition from {current.GetType().Name} with {message.GetType().Name}")
};Nullable contracts for optional data:
public interface IThumbnailStore
{
Task<string?> TryGetThumbnailPathAsync(string defectId, CancellationToken ct);
}And result modeling for operations where absence is not enough:
public sealed record Result<T>(bool IsSuccess, T? Value, string? Error)
{
public static Result<T> Success(T value) => new(true, value, null);
public static Result<T> Failure(string error) => new(false, default, error);
}This combination is effective because it makes the code easier to reason about:
- records say “this is data”
- pattern matching says “these are the rules”
- nullability says “this might be absent”
- result types say “this can fail meaningfully”
That is exactly what you want in workflow-heavy desktop software where correctness matters more than cleverness.
Part 10 — How we use this in .NET, practically
Here is a more realistic slice that pulls several features together without becoming fancy.
#nullable enable
public sealed record InspectionResult(
string RunId,
string WaferId,
int TotalDies,
int TotalDefects,
DateTime CompletedAtUtc,
string? SummaryImagePath);
public sealed record ServiceError(string Code, string Message);
public sealed record Result<T>(T? Value, ServiceError? Error)
{
public bool IsSuccess => Error is null;
public static Result<T> Success(T value) => new(value, null);
public static Result<T> Failure(string code, string message) =>
new(default, new ServiceError(code, message));
}
public interface IInspectionRunRepository
{
Task<InspectionRun?> FindAsync(string runId, CancellationToken ct);
Task SaveResultAsync(InspectionResult result, CancellationToken ct);
}
public sealed class CompleteInspectionRunHandler(
IInspectionRunRepository repository,
ILogger<CompleteInspectionRunHandler> logger)
{
public async Task<Result<InspectionResult>> HandleAsync(
string runId,
Func<InspectionRun, CancellationToken, Task<string?>> generateSummaryImage,
CancellationToken ct)
{
var run = await repository.FindAsync(runId, ct);
if (run is null)
return Result<InspectionResult>.Failure("run_not_found", $"Run {runId} was not found.");
if (run is not { Status: RunStatus.Completed })
return Result<InspectionResult>.Failure("run_not_completed", $"Run {runId} is not completed.");
string? summaryImagePath;
try
{
summaryImagePath = await generateSummaryImage(run, ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed generating summary image for run {RunId}", runId);
return Result<InspectionResult>.Failure("summary_image_failed", ex.Message);
}
var result = new InspectionResult(
run.RunId,
run.WaferId,
run.TotalDies,
run.TotalDefects,
run.CompletedAtUtc ?? DateTime.UtcNow,
summaryImagePath);
await repository.SaveResultAsync(result, ct);
logger.LogInformation(
"Completed run {RunId} with {DefectCount} defects",
result.RunId,
result.TotalDefects);
return Result<InspectionResult>.Success(result);
}
}Why this is good:
InspectionResult,ServiceError, andResult<T>are record-like data carriers.FindAsyncreturns nullable because “not found” is a legitimate absence.Result<T>is used when the caller needs structured failure information.- Pattern matching checks the run state clearly.
- Delegate injection is used for one small pluggable behavior: summary image generation.
- The syntax is modern, but still plain enough for a large team.
That is the sweet spot.
Part 11 — Common mistakes
One common mistake is using new syntax because it feels more senior. That usually produces code that is technically modern but socially expensive. A maintenance developer now has to mentally decode syntax before they can understand the business rule.
Another common mistake is overly clever pattern matching. The feature is strong, but once a switch expression becomes a tiny DSL with nested patterns, guards, and complex tuple logic, it stops helping. In production, someone will eventually need to set a breakpoint in the middle of it at 2 AM.
Another mistake is replacing every class with records. That often blurs the line between immutable data and stateful behavior. The result is confusing equality semantics, misuse of with, and types that look like values but behave like services or entities.
Overusing LINQ in hot paths is another very real problem. The code looks elegant in review, but the processing pipeline starts allocating too much under load or obscures control flow when performance work begins.
A subtler mistake is hiding too much logic in expressions. Expression-bodied members, nested ternaries, and compressed lambdas can all look neat while making diagnostics worse.
Teams also hurt themselves by mixing too many styles at once: old imperative code, hyper-modern pattern-heavy code, record-centric code, and functional delegate-heavy code all in the same subsystem. Even individually good features become a maintenance cost when the codebase has no coherent style.
And finally, nullability warnings are often ignored instead of fixed. That throws away one of the best safety tools the language gives you. Microsoft’s nullable docs and warning guidance exist for a reason: the compiler is telling you where your contract is unclear. (Microsoft Learn)
Part 12 — Trade-offs
Conciseness versus readability is the most obvious trade-off. Shorter code is not always clearer code. In fact, C#’s newer features make it easier than ever to write dense code that feels elegant locally but is harder to debug globally.
Modern syntax versus team familiarity is another. A feature may be objectively good, but if only two people on the team use it confidently, your codebase becomes uneven. Microsoft’s coding conventions guidance is relevant here: consistency is a core part of maintainability. (Microsoft Learn)
Immutability versus allocation cost matters in result-heavy and event-heavy systems. Immutable records are excellent for reasoning and safety, but if you create huge chains of transient objects in a tight processing loop, allocation pressure can become real. In a workflow layer, that cost is usually worth it. In a per-frame hot path, maybe not.
Delegate-based flexibility versus explicit design is another real trade-off. Delegates can remove a lot of ceremony, but interfaces are often easier to discover, document, and test when the behavior becomes architecturally important.
Expressive code versus debuggability might be the most practical one of all. A loop with a few if statements can be less elegant than a nested expression-based pipeline, but much easier to step through during an incident. Experienced engineers choose based on where the code lives and how often it will be diagnosed under pressure.
Part 13 — Code review and team standards
When I review modern C# usage as a senior engineer, I mostly ask five questions.
First: does this feature make the intent more obvious?
Second: does it improve the contract? Nullability, immutability, and result modeling are often good because they make promises more explicit.
Third: is the syntax proportionate to the problem? A three-case classifier is a great switch expression. A fifteen-rule policy engine may deserve named methods or dedicated types.
Fourth: will this still be understandable after the original author forgets the details?
Fifth: is this consistent with how the rest of the team writes C#?
A good team standard is not “use every modern feature.” It is more like:
- use pattern matching for clear classification
- use records for immutable data carriers
- enable nullable reference types and fix warnings honestly
- prefer LINQ for clarity outside hot paths
- use construction shortcuts when the target type is obvious
- avoid code-golf syntax in core business logic
- favor one consistent style per subsystem
Newer features should be introduced gradually, usually by example and review guidance, not by sudden codebase-wide stylistic swings. Otherwise the repository becomes fragmented into time periods and author preferences.
Part 14 — Senior engineer mental model
Experienced engineers treat language features as tools, not decoration. The goal is not to make code look modern. The goal is to reduce accidental complexity.
That usually means:
- use the type system to make illegal states harder to represent
- use syntax that exposes decision rules clearly
- separate immutable data from stateful behavior
- keep construction honest
- make optionality visible
- prefer boring clarity over clever density
The phrase I would use in an interview is this: good modern C# makes important code easier to reason about, not harder to admire.
For a production WPF system controlling a wafer inspection machine, that means the best code often looks something like this:
- pattern matching for event/state classification
- records for immutable messages and results
- nullable annotations for honest service contracts
- local functions for keeping orchestration readable
- selective delegate use for lightweight extensibility
- plain classes for services, controllers, resource owners, and mutable ViewModels
- LINQ in the reporting/UI layer, explicit loops in hot paths
That is modern, but still boring in the best possible way.
If you want, I can turn this into a second pass that is even more interview-focused, with likely follow-up questions and strong senior-level answers.