Skip to content

Domain modeling in .NET systems

This topic matters much more in real systems than many teams realize.

When people hear “domain modeling,” they sometimes think of abstract DDD discussions, diagrams, or fancy vocabulary. But in production, domain modeling is really about a simpler question:

Where does the truth of the system live?

In a serious .NET system, especially a WPF desktop application controlling real hardware, that question becomes critical. If the truth is spread across ViewModels, service methods, validators, helper classes, SDK wrappers, and random if statements, the system becomes fragile. Rules drift apart. Behavior becomes inconsistent. Bugs appear in places that feel unrelated. Small changes become expensive.

A good domain model does not mean “make everything complicated.” It means the important concepts in the system should behave like the real concepts they represent. A Recipe should know enough about itself to prevent obviously invalid configurations. An InspectionRun should know whether it can transition from Paused to Completed or not. A machine command should not be executable just because some button happened to be enabled.

That is the real goal: not elegance for its own sake, but clarity, correctness, and changeability.


1. Big picture

Domain modeling matters because real systems are not just data containers. They are systems of rules, transitions, constraints, and meaning.

A wafer inspection machine is a good example. On the surface, it looks like a lot of structured data:

  • recipe name
  • wafer size
  • scan speed
  • camera exposure
  • defect thresholds
  • run status
  • alarms
  • classification results

But the real behavior of the system is not in the shape of that data. The real behavior is in questions like:

  • Can this recipe be used on this machine configuration?
  • Can a run start if calibration is stale?
  • Can the machine accept a motion command while homing is incomplete?
  • Can a defect be marked final before review data exists?
  • Can a run be completed if some images are still being processed?

Those are domain questions. If the answers live everywhere, the codebase becomes hard to understand.

This is why “data shape alone” is not enough. A model that only says what fields exist is useful for transport and storage, but it does not protect business meaning. It cannot stop invalid transitions. It cannot keep related rules together. It cannot make the code read like the system.

In large .NET systems, business and workflow rules often get scattered into:

  • ViewModels
  • command handlers
  • workflow services
  • validation utilities
  • persistence mappers
  • device wrappers
  • background processors

Once that happens, the system becomes harder to maintain for a simple reason: no single place explains how an important concept is supposed to behave.

A good domain model improves this by making the code read closer to the business language:

  • recipe.ValidateAgainst(machineCapabilities)
  • run.Start(clock.Now, operatorId)
  • run.Pause(reason)
  • machineSession.CanExecute(MachineCommand.StartScan)
  • defect.FinalizeClassification(classification, reviewer)

That is easier to read, easier to test, and easier to change than hunting through five services and two ViewModels.


2. Rich model vs anemic model

Let’s make this very concrete.

An anemic model is a model made mostly of data, with little or no behavior. It often looks like this:

csharp
public class RecipeDto
{
    public string Name { get; set; } = "";
    public double ScanSpeed { get; set; }
    public double ExposureTimeMs { get; set; }
    public double Threshold { get; set; }
    public bool IsValidated { get; set; }
}

The logic then lives somewhere else:

csharp
public class RecipeValidatorService
{
    public ValidationResult Validate(RecipeDto recipe, MachineCapabilities caps)
    {
        // validation logic here
    }
}

And maybe start logic lives elsewhere too:

csharp
public class RunService
{
    public void StartRun(RecipeDto recipe, MachineStateDto machineState)
    {
        // check recipe flags
        // check machine state
        // update run state
    }
}

This is common in .NET systems because it feels simple at first. It is also easy to serialize, persist, bind to UI, and move across layers.

A richer model gives important concepts behavior and guards their own validity:

csharp
public sealed class Recipe
{
    private readonly List<string> _validationErrors = new();

    public string Name { get; }
    public ScanParameters ScanParameters { get; }
    public ThresholdParameters Thresholds { get; }

    public bool IsValidated => _validationErrors.Count == 0;
    public IReadOnlyList<string> ValidationErrors => _validationErrors;

    private Recipe(string name, ScanParameters scanParameters, ThresholdParameters thresholds)
    {
        Name = string.IsNullOrWhiteSpace(name)
            ? throw new ArgumentException("Recipe name is required.")
            : name;

        ScanParameters = scanParameters;
        Thresholds = thresholds;
    }

    public static Recipe Create(string name, ScanParameters scanParameters, ThresholdParameters thresholds)
        => new(name, scanParameters, thresholds);

    public void ValidateAgainst(MachineCapabilities capabilities)
    {
        _validationErrors.Clear();

        if (ScanParameters.SpeedMmPerSec > capabilities.MaxScanSpeedMmPerSec)
            _validationErrors.Add("Scan speed exceeds machine capability.");

        if (ScanParameters.ExposureTimeMs > capabilities.MaxExposureTimeMs)
            _validationErrors.Add("Exposure exceeds supported range.");

        if (Thresholds.DefectSensitivity <= 0)
            _validationErrors.Add("Defect sensitivity must be greater than zero.");
    }

    public void EnsureUsableForRun()
    {
        if (!IsValidated)
            throw new InvalidOperationException("Recipe must be validated before starting a run.");
    }
}

This is not “DDD theater.” It is simply putting behavior near the concept it belongs to.

Why many .NET systems drift into anemic models

There are a few practical reasons:

First, many teams start from database tables, API contracts, or UI forms. So the initial model becomes “what fields do we store,” not “what concept are we modeling.”

Second, frameworks reward passive objects. ORMs, serializers, mappers, grids, and UI binding all work more easily with simple data containers.

Third, service-oriented application structure often encourages “all logic goes into services.” So people end up with RecipeService, RunService, MachineService, WorkflowManager, ValidationUtil, and the actual objects become passive bags of properties.

Fourth, teams are sometimes afraid that putting behavior in models will make them “too smart” or “too coupled.” So they overcorrect and move all behavior out.

When an anemic model is acceptable

An anemic model is perfectly acceptable when the object is mainly:

  • a transport contract
  • a persistence projection
  • a UI snapshot
  • a reporting row
  • a simple configuration record with almost no behavior

For example:

  • a row shown in a grid
  • a message sent over a queue
  • a read model for a dashboard
  • a flat DTO returned from a repository

Those do not need rich behavior.

When richer behavior becomes valuable

A richer model becomes valuable when the concept:

  • has rules
  • has lifecycle
  • has valid/invalid transitions
  • must protect invariants
  • becomes expensive when logic is duplicated
  • is central to the business meaning of the system

In the wafer inspection system, InspectionRun, Recipe, MachineSession, and DefectClassification are good candidates for richer modeling because they are not just data. They have meaning over time.


3. Real problems in the wafer inspection system

Let’s imagine a WPF desktop application controlling a wafer inspection machine.

This system probably has:

  • screens to edit recipes
  • services to validate recipes
  • ViewModels for machine controls
  • background workflows for runs
  • processors for image and defect data
  • adapters for machine SDKs
  • alarm handling
  • result summary generation

Now imagine weak modeling.

Problem 1: recipe rules scattered everywhere

The UI disables the Start button if some fields look wrong.

The recipe editor ViewModel checks wafer size compatibility.

A validator service checks exposure ranges.

The run workflow service checks whether the recipe was “validated.”

The machine adapter does its own safety checks again before motion.

Now the rule “recipe must be valid before use” exists in four places, but not in one clear place. Over time those checks drift. One uses stale machine capability limits. Another ignores a new autofocus rule. Another only checks in manual mode.

Production result: a recipe works from one screen but fails from another path.

Problem 2: run-state rules duplicated across ViewModel and workflow

The WPF ViewModel contains flags like:

csharp
IsIdle
IsRunning
IsPaused
CanStart
CanPause
CanResume
CanStop

The workflow service has another state model internally.

The machine callback handler also has separate flags.

Soon you get contradictions:

  • UI thinks Resume is allowed
  • workflow thinks run is already stopping
  • machine SDK is still processing final frames
  • result pipeline has not finalized summaries yet

Production result: race conditions, invalid buttons, confusing operator behavior, inconsistent logs.

Problem 3: invalid commands allowed because invariants are not centralized

Suppose the operator can issue StartScan, MoveStage, or UnloadWafer.

If command validity is enforced only in UI enable/disable logic, then other entry points can bypass it:

  • keyboard shortcuts
  • automation scripts
  • retry logic
  • remote control hooks
  • background workflow recovery

Production result: a command executes in a state where it should have been impossible.

Problem 4: defect logic spread across processors and helpers

The image processor creates defect candidates.

A helper converts coordinates.

A classification service applies thresholds.

A review ViewModel adds manual overrides.

A summarizer decides final counts.

Now nobody can answer a simple question: “What exactly makes a defect final?”

Production result: inconsistent counts between screens, exports, and database records.

Problem 5: hard-to-change business logic

When the model has no real behavior, every change requires hunting through scattered services and UI code.

For example, a new rule appears:

Runs may only be completed if all defect review-required items are resolved.

If the logic is scattered, this affects:

  • completion command logic
  • summary logic
  • UI state
  • export logic
  • alarm logic
  • maybe machine unload logic

With a stronger domain model, that rule belongs near the InspectionRun completion logic and related outcome rules.


4. Modeling core domain concepts

Here is how experienced engineers usually think about core concepts: not “where can I store these fields?” but “what is this thing responsible for?”

InspectionRun

What data belongs there

  • run identifier
  • recipe/version used
  • wafer identifier / batch identifier
  • current lifecycle state
  • timestamps
  • pause/resume history
  • completion status
  • summary references
  • failure or termination outcome

What behavior belongs there

  • start
  • pause
  • resume
  • request stop
  • complete
  • fail
  • ensure valid transition
  • track lifecycle facts
  • decide whether finalization is allowed

What stays outside

  • talking to camera SDK
  • moving stage hardware
  • loading images
  • saving to database
  • coordinating multiple subsystems across process boundaries

How to protect invariants

InspectionRun should not allow impossible transitions.

Examples:

  • cannot complete before starting
  • cannot resume if not paused
  • cannot start if recipe invalid
  • cannot finalize while pending result processing exists

A good run model does not replace orchestration, but it does protect local correctness.


Recipe

What data belongs there

  • identity/version
  • scan parameters
  • optical parameters
  • defect thresholds
  • wafer constraints
  • feature toggles relevant to inspection behavior
  • validation state or issues derived from validation

What behavior belongs there

  • validate against machine capabilities
  • validate consistency of internal parameters
  • determine if usable for a run
  • apply safe modifications through explicit methods

What stays outside

  • reading machine capabilities from SDK
  • retrieving calibration baselines
  • persistence
  • UI formatting

How to protect invariants

Examples:

  • threshold ranges must be positive and ordered correctly
  • scan regions must stay within wafer limits
  • incompatible options cannot both be enabled
  • recipe version becomes immutable once approved

MachineSession / MachineState

These are related but not identical.

MachineState is often a snapshot or value-like representation of current machine condition.

MachineSession is often the richer concept representing a connected, active operating session with command intent and state rules.

Data

  • connection status
  • homed/not homed
  • interlock status
  • door state
  • current operating mode
  • fault state
  • readiness indicators
  • current command or activity

Behavior

  • evaluate readiness
  • determine whether a command is allowed
  • transition local machine session status
  • acknowledge alarms
  • enter/exit maintenance mode where permitted

Outside

  • SDK invocation
  • signal polling
  • PLC communication
  • low-level retries

Invariants

Examples:

  • cannot start scan while interlock open
  • cannot move axis before homing complete
  • cannot enter production-ready state while a critical alarm is active

Defect / DefectClassification

Data

  • defect identity
  • source frame/region
  • location
  • size/shape measures
  • confidence
  • assigned classification
  • review metadata
  • finalization status

Behavior

  • assign candidate classification
  • override classification under controlled rules
  • finalize classification when required data exists
  • reject invalid changes after finalization

Outside

  • image analysis algorithms
  • ML inference
  • persistence
  • UI review workflows spanning multiple defects

Invariants

Examples:

  • cannot finalize without mandatory review metadata
  • cannot change finalized classification except through explicit re-open flow
  • defect region must have valid bounds

RunSummary

Data

  • counts
  • yield metrics
  • defect breakdown
  • timing summary
  • run outcome

Behavior

Usually limited. This is often closer to a derived model or value-rich report object.

It may contain domain logic such as:

  • calculating final yield category
  • determining pass/fail outcome
  • exposing aggregate consistency checks

But it usually should not orchestrate workflow.


Alarm / Warning / FailureOutcome

These should usually be modeled more explicitly than just strings or enums scattered everywhere.

Data

  • code
  • severity
  • source
  • timestamp
  • acknowledgment state
  • blocking/non-blocking indicator
  • failure reason category

Behavior

  • acknowledge
  • classify severity
  • determine if blocking
  • convert to domain outcome when needed

Outside

  • actual alarm transport
  • PLC read/write
  • UI display formatting

5. Invariants, rules, and valid operations

An invariant is a rule that should always be true for a valid object or valid state.

This is one of the most useful ideas in domain modeling.

It is not about academic purity. It is about making sure that important objects cannot quietly become nonsense.

Examples of invariants

  • a recipe cannot be used unless validated
  • a run cannot be completed unless started
  • a machine command cannot execute in an unsafe state
  • a finalized defect classification must have required supporting data
  • a stage coordinate must lie within allowed bounds

The main benefit is that invalid state becomes harder to represent.

That is a very good design goal.

In weakly modeled systems, invalid states are easy to create and only rejected much later. That means bugs travel through the system until they fail in confusing places.

In stronger models, the failure happens closer to the cause.

Example: run transitions

Bad style:

csharp
run.Status = RunStatus.Completed;

This allows any caller to skip rules.

Better:

csharp
run.Complete(finalizationResult);

Inside:

csharp
public void Complete(RunFinalizationResult finalizationResult)
{
    if (State != InspectionRunState.Stopping && State != InspectionRunState.Finalizing)
        throw new InvalidOperationException($"Run cannot complete from state {State}.");

    if (!finalizationResult.AllReviewItemsResolved)
        throw new InvalidOperationException("Run cannot complete while review-required items remain unresolved.");

    State = InspectionRunState.Completed;
    CompletedAtUtc = finalizationResult.CompletedAtUtc;
    Summary = finalizationResult.Summary;
}

Now the object enforces the rule.

Example: recipe validity

Bad style:

csharp
if (recipe.IsValidated)
{
    StartRun();
}

This relies on some external flag that may be stale.

Better:

csharp
recipe.EnsureUsableForRun();

That forces the concept itself to express what “usable” means.

Experienced engineers try to make rules explicit in code through:

  • constructors that reject invalid creation
  • factory methods for controlled creation
  • methods that enforce valid transitions
  • value objects for constrained inputs
  • internal/private setters
  • explicit domain methods instead of raw property mutation

6. Where behavior should live

This is one of the most important boundaries.

A lot of confusion comes from not distinguishing:

  • domain behavior
  • application orchestration
  • infrastructure behavior
  • UI behavior

Domain objects

Domain objects should contain behavior that belongs to the concept itself.

Examples:

  • Recipe.ValidateAgainst(capabilities)
  • InspectionRun.Pause(reason)
  • MachineSession.CanExecute(command)
  • Defect.FinalizeClassification(review)

This is concept-local behavior. It protects meaning and validity.

Application/workflow services

Application services coordinate use cases across multiple domain objects and infrastructure pieces.

Examples:

  • load recipe from repository
  • query machine readiness from adapter
  • ask run to start
  • tell machine adapter to start scan
  • persist run update
  • publish event
  • update UI notification stream

This is orchestration.

The workflow service should not decide the internal rules of InspectionRun if those rules belong to the run itself. It should coordinate the steps.

Example:

csharp
public sealed class StartInspectionRunService
{
    public async Task StartAsync(RunId runId, RecipeId recipeId, CancellationToken ct)
    {
        var run = await _runRepository.GetRequired(runId, ct);
        var recipe = await _recipeRepository.GetRequired(recipeId, ct);
        var capabilities = await _machineAdapter.GetCapabilitiesAsync(ct);
        var readiness = await _machineAdapter.GetReadinessAsync(ct);

        recipe.ValidateAgainst(capabilities);
        recipe.EnsureUsableForRun();

        run.Start(recipe, readiness, _clock.UtcNow);

        await _machineAdapter.StartInspectionAsync(run.Id, recipe, ct);
        await _runRepository.SaveAsync(run, ct);
    }
}

The service coordinates. The domain objects decide whether the action is locally valid.

Infrastructure/adapters

Infrastructure talks to external systems:

  • machine SDK
  • PLC
  • camera SDK
  • file system
  • DB
  • message bus

It should not define the business meaning of a valid run or valid recipe, except for technical constraints it exposes.

For example, the machine adapter can say:

  • camera disconnected
  • axis not homed
  • exposure range supported is X-Y

But the domain model should decide what those facts mean for use cases like starting a run.

ViewModels

ViewModels should manage presentation state and user interaction flow.

They should not be the primary home of domain rules.

Good ViewModel responsibilities:

  • bind screen fields
  • relay commands
  • show validation messages
  • format state for UI
  • react to progress and notifications

Bad ViewModel responsibilities:

  • define run lifecycle rules
  • decide unsafe machine command policy
  • own recipe consistency rules
  • compute domain truth differently from backend/application logic

The UI can reflect rules, but should not be the main source of them.


7. Domain modeling vs services-everywhere design

Many .NET codebases end up with services everywhere because that style feels straightforward.

You create:

  • RunService
  • RecipeService
  • RecipeHelper
  • ValidationUtil
  • WorkflowManager
  • MachineManager
  • ResultProcessor
  • DefectClassifierService

And the model objects remain passive.

Why does this happen?

Because services feel flexible. They do not fight serializers. They are easy to inject. They seem to centralize behavior.

But over time this creates accidental complexity.

Problem 1: too many coordination points

When all rules live in services, one use case touches multiple service layers to decide something simple.

Problem 2: ownership becomes unclear

Who owns the rule “paused runs cannot finalize”? RunService? WorkflowManager? FinalizationService? ViewModel?

Nobody knows.

Problem 3: domain language disappears

Instead of reading:

csharp
run.Resume();

You read:

csharp
_workflowManager.TryResumeRun(runDto, context, flags, options);

That is much harder to reason about.

Problem 4: service sprawl

Helpers, managers, utils, processors, and services multiply because passive objects force every behavior to live elsewhere.

How richer models reduce accidental complexity

A richer model gives obvious rules an obvious home.

Not everything moves into the domain, but enough behavior moves there that the system becomes easier to read.

For example:

Bad:

  • RunService.ValidateCanPause
  • WorkflowManager.PerformPause
  • ViewModel.CanPause
  • AlarmUtil.CheckPauseAllowed

Better:

  • run.CanPause
  • run.Pause(reason)
  • application service coordinates actual hardware and persistence
  • ViewModel asks application service or reflects the domain state

That does not eliminate services. It makes them less bloated and more purposeful.


8. Modeling state, workflow, and lifecycle

Domain modeling and state machines are closely related in systems with lifecycle.

An InspectionRun is not just data. It moves through states.

For example:

  • Created
  • Validating
  • Ready
  • Running
  • Paused
  • Stopping
  • Finalizing
  • Completed
  • Failed
  • Aborted

The more important the lifecycle, the more valuable it is to model it explicitly.

Why explicit lifecycle helps

It makes transitions understandable.

It makes illegal transitions obvious.

It improves logs, monitoring, testability, and incident analysis.

It reduces boolean-flag chaos like:

csharp
IsStarted
IsStopped
IsPaused
IsCompleted
HasFailed
IsFinalizing

Those flags often allow contradictory combinations.

An enum or explicit state abstraction is much clearer.

Example

csharp
public enum InspectionRunState
{
    Created,
    Ready,
    Running,
    Paused,
    Stopping,
    Finalizing,
    Completed,
    Failed,
    Aborted
}

Then methods enforce transitions:

csharp
public void Pause(string reason)
{
    if (State != InspectionRunState.Running)
        throw new InvalidOperationException("Only a running run can be paused.");

    State = InspectionRunState.Paused;
    PauseHistory.Add(new RunPauseRecord(_clock.UtcNow, reason));
}

public void Resume()
{
    if (State != InspectionRunState.Paused)
        throw new InvalidOperationException("Only a paused run can be resumed.");

    State = InspectionRunState.Running;
}

Local correctness vs system-wide workflow

The domain object should enforce local correctness.

But the orchestration layer still coordinates broader system workflow.

For example:

  • InspectionRun.Resume() enforces valid transition
  • workflow service ensures machine is still ready, image pipeline is healthy, and operator permissions are valid
  • adapter calls hardware SDK
  • repository persists result

This is an important mental split:

  • domain object: “Is this transition conceptually valid?”
  • orchestrator: “Can the whole system perform this operation now?”

Both matter.


9. Value objects, entities, and identity

This topic is often explained too academically. In practice, the question is simple:

Does this concept matter because of what it is, or because of which specific instance it is over time?

Value objects

Use value objects for things defined mainly by their values, especially when they have rules and meaning.

Great candidates:

  • coordinates
  • dimensions
  • thresholds
  • wafer diameter
  • exposure settings
  • scan region
  • defect region
  • calibration tolerance
  • recipe parameter groups

Example:

csharp
public readonly record struct StageCoordinate(double XUm, double YUm)
{
    public static StageCoordinate Create(double xUm, double yUm)
    {
        if (xUm < 0 || yUm < 0)
            throw new ArgumentOutOfRangeException("Coordinates must be non-negative.");

        return new StageCoordinate(xUm, yUm);
    }
}

This is better than passing raw doubles everywhere.

Why?

Because the type carries meaning. It can protect invariants. It reduces parameter confusion.

Value objects improve clarity

Compare:

csharp
MoveTo(1200, 3500);

vs

csharp
MoveTo(StageCoordinate.Create(1200, 3500));

The second is more expressive and safer.

Entities

Use entities for concepts with identity over time.

Examples:

  • inspection run
  • machine session
  • defect
  • recipe version
  • review task

A defect is not just “a region with values.” It is a tracked thing with lifecycle, overrides, history, maybe comments, maybe review decisions. Identity matters.

When equality matters

For value objects, equality by content is usually right.

For entities, equality by identity is usually right.

That matters for collections, caching, comparison, deduplication, and test expectations.


10. Modern C# and domain modeling

Modern C# can help domain modeling a lot, as long as you use it to improve clarity rather than show off syntax.

Records

Records are useful for immutable data-like concepts and snapshots.

Good uses:

  • value objects
  • result snapshots
  • configuration snapshots
  • summaries
  • read models

Example:

csharp
public sealed record ThresholdParameters(
    double DefectSensitivity,
    double MinAreaUm2,
    double MaxAreaUm2);

Records are less ideal for behavior-rich lifecycle-heavy entities that change over time in controlled ways. You can still use them, but classes usually fit better when the concept has identity and protected mutation.

Classes

Classes are usually better for:

  • entities
  • lifecycle-heavy objects
  • models with explicit transition methods
  • objects with internal collections and controlled state changes

Pattern matching

Pattern matching helps domain decisions read clearly.

csharp
public bool CanExecute(MachineCommand command) =>
    (State, command) switch
    {
        (MachineOperationalState.Ready, MachineCommand.StartInspection) => true,
        (MachineOperationalState.Running, MachineCommand.Pause) => true,
        (MachineOperationalState.Paused, MachineCommand.Resume) => true,
        (MachineOperationalState.Paused, MachineCommand.Stop) => true,
        _ => false
    };

This can be much clearer than nested conditionals.

Nullable reference types

Nullable reference types are very useful for invariants.

If CompletedAtUtc is only set after completion, say so explicitly:

csharp
public DateTimeOffset? CompletedAtUtc { get; private set; }
public RunSummary? Summary { get; private set; }

Then methods can enforce when those values become non-null.

This makes state expectations more explicit both for developers and the compiler.

Constructors and factory methods

These help keep models valid from birth.

Instead of:

csharp
var recipe = new Recipe();
recipe.Name = name;
recipe.ScanSpeed = speed;
recipe.Exposure = exposure;

prefer controlled creation:

csharp
var recipe = Recipe.Create(name, scanParameters, thresholds);

That prevents partially initialized nonsense objects.


11. Practical .NET examples

Here is a realistic sketch.

A richer Recipe model

csharp
public sealed class Recipe
{
    private readonly List<string> _validationErrors = new();

    public RecipeId Id { get; }
    public string Name { get; private set; }
    public RecipeVersion Version { get; }
    public ScanParameters ScanParameters { get; private set; }
    public ThresholdParameters Thresholds { get; private set; }

    public IReadOnlyList<string> ValidationErrors => _validationErrors;
    public bool IsValidated => _validationErrors.Count == 0;

    private Recipe(
        RecipeId id,
        string name,
        RecipeVersion version,
        ScanParameters scanParameters,
        ThresholdParameters thresholds)
    {
        Id = id;
        Name = string.IsNullOrWhiteSpace(name)
            ? throw new ArgumentException("Recipe name is required.")
            : name;

        Version = version;
        ScanParameters = scanParameters;
        Thresholds = thresholds;
    }

    public static Recipe CreateNew(
        string name,
        ScanParameters scanParameters,
        ThresholdParameters thresholds)
    {
        return new Recipe(
            RecipeId.New(),
            name,
            RecipeVersion.Initial(),
            scanParameters,
            thresholds);
    }

    public void Rename(string newName)
    {
        if (string.IsNullOrWhiteSpace(newName))
            throw new ArgumentException("Recipe name is required.");

        Name = newName.Trim();
    }

    public void UpdateThresholds(ThresholdParameters thresholds)
    {
        Thresholds = thresholds;
    }

    public void ValidateAgainst(MachineCapabilities capabilities)
    {
        _validationErrors.Clear();

        if (ScanParameters.SpeedMmPerSec <= 0)
            _validationErrors.Add("Scan speed must be greater than zero.");

        if (ScanParameters.SpeedMmPerSec > capabilities.MaxScanSpeedMmPerSec)
            _validationErrors.Add("Scan speed exceeds machine capability.");

        if (ScanParameters.ExposureTimeMs <= 0)
            _validationErrors.Add("Exposure time must be greater than zero.");

        if (ScanParameters.ExposureTimeMs > capabilities.MaxExposureTimeMs)
            _validationErrors.Add("Exposure time exceeds machine capability.");

        if (Thresholds.MinAreaUm2 > Thresholds.MaxAreaUm2)
            _validationErrors.Add("Minimum area cannot exceed maximum area.");
    }

    public void EnsureUsableForRun()
    {
        if (!IsValidated)
            throw new InvalidOperationException("Recipe is not valid for execution.");
    }
}

InspectionRun protecting valid transitions

csharp
public sealed class InspectionRun
{
    public RunId Id { get; }
    public InspectionRunState State { get; private set; }
    public RecipeId RecipeId { get; private set; }
    public DateTimeOffset? StartedAtUtc { get; private set; }
    public DateTimeOffset? CompletedAtUtc { get; private set; }
    public RunSummary? Summary { get; private set; }
    public string? FailureReason { get; private set; }

    private readonly List<RunPauseRecord> _pauseHistory = new();
    public IReadOnlyCollection<RunPauseRecord> PauseHistory => _pauseHistory;

    public InspectionRun(RunId id, RecipeId recipeId)
    {
        Id = id;
        RecipeId = recipeId;
        State = InspectionRunState.Created;
    }

    public void MarkReady()
    {
        if (State != InspectionRunState.Created)
            throw new InvalidOperationException("Only a created run can become ready.");

        State = InspectionRunState.Ready;
    }

    public void Start(Recipe recipe, MachineReadiness readiness, DateTimeOffset nowUtc)
    {
        if (State != InspectionRunState.Ready)
            throw new InvalidOperationException("Run must be ready before starting.");

        recipe.EnsureUsableForRun();

        if (!readiness.IsReady)
            throw new InvalidOperationException($"Machine is not ready: {readiness.Reason}");

        State = InspectionRunState.Running;
        StartedAtUtc = nowUtc;
    }

    public void Pause(DateTimeOffset nowUtc, string reason)
    {
        if (State != InspectionRunState.Running)
            throw new InvalidOperationException("Only a running run can be paused.");

        State = InspectionRunState.Paused;
        _pauseHistory.Add(new RunPauseRecord(nowUtc, reason));
    }

    public void Resume()
    {
        if (State != InspectionRunState.Paused)
            throw new InvalidOperationException("Only a paused run can be resumed.");

        State = InspectionRunState.Running;
    }

    public void RequestStop()
    {
        if (State is not (InspectionRunState.Running or InspectionRunState.Paused))
            throw new InvalidOperationException("Stop is only valid from running or paused state.");

        State = InspectionRunState.Stopping;
    }

    public void BeginFinalization()
    {
        if (State != InspectionRunState.Stopping)
            throw new InvalidOperationException("Finalization can begin only after stopping.");

        State = InspectionRunState.Finalizing;
    }

    public void Complete(RunSummary summary, DateTimeOffset nowUtc, bool allReviewItemsResolved)
    {
        if (State != InspectionRunState.Finalizing)
            throw new InvalidOperationException("Run must be finalizing before completion.");

        if (!allReviewItemsResolved)
            throw new InvalidOperationException("Cannot complete while review-required items remain unresolved.");

        Summary = summary;
        CompletedAtUtc = nowUtc;
        State = InspectionRunState.Completed;
    }

    public void Fail(string reason)
    {
        if (State is InspectionRunState.Completed or InspectionRunState.Aborted)
            throw new InvalidOperationException("Completed or aborted runs cannot fail.");

        FailureReason = string.IsNullOrWhiteSpace(reason) ? "Unknown failure" : reason;
        State = InspectionRunState.Failed;
    }
}

Small value objects

csharp
public readonly record struct RecipeId(Guid Value)
{
    public static RecipeId New() => new(Guid.NewGuid());
}

public readonly record struct RunId(Guid Value)
{
    public static RunId New() => new(Guid.NewGuid());
}

public readonly record struct RecipeVersion(int Value)
{
    public static RecipeVersion Initial() => new(1);

    public RecipeVersion Next() => new(Value + 1);
}

public readonly record struct ScanParameters(double SpeedMmPerSec, double ExposureTimeMs)
{
    public ScanParameters
    {
        if (SpeedMmPerSec <= 0) throw new ArgumentOutOfRangeException(nameof(SpeedMmPerSec));
        if (ExposureTimeMs <= 0) throw new ArgumentOutOfRangeException(nameof(ExposureTimeMs));
    }
}

public readonly record struct ThresholdParameters(double DefectSensitivity, double MinAreaUm2, double MaxAreaUm2)
{
    public ThresholdParameters
    {
        if (DefectSensitivity <= 0) throw new ArgumentOutOfRangeException(nameof(DefectSensitivity));
        if (MinAreaUm2 < 0) throw new ArgumentOutOfRangeException(nameof(MinAreaUm2));
        if (MaxAreaUm2 <= 0) throw new ArgumentOutOfRangeException(nameof(MaxAreaUm2));
        if (MinAreaUm2 > MaxAreaUm2) throw new ArgumentException("Min area cannot exceed max area.");
    }
}

Application service orchestration

csharp
public sealed class InspectionRunApplicationService
{
    private readonly IInspectionRunRepository _runRepository;
    private readonly IRecipeRepository _recipeRepository;
    private readonly IMachineAdapter _machineAdapter;
    private readonly IClock _clock;

    public InspectionRunApplicationService(
        IInspectionRunRepository runRepository,
        IRecipeRepository recipeRepository,
        IMachineAdapter machineAdapter,
        IClock clock)
    {
        _runRepository = runRepository;
        _recipeRepository = recipeRepository;
        _machineAdapter = machineAdapter;
        _clock = clock;
    }

    public async Task StartRunAsync(RunId runId, CancellationToken ct)
    {
        var run = await _runRepository.GetRequiredAsync(runId, ct);
        var recipe = await _recipeRepository.GetRequiredAsync(run.RecipeId, ct);

        var capabilities = await _machineAdapter.GetCapabilitiesAsync(ct);
        var readiness = await _machineAdapter.GetReadinessAsync(ct);

        recipe.ValidateAgainst(capabilities);
        run.Start(recipe, readiness, _clock.UtcNow);

        await _machineAdapter.StartInspectionAsync(run.Id, recipe, ct);
        await _runRepository.SaveAsync(run, ct);
    }

    public async Task PauseRunAsync(RunId runId, string reason, CancellationToken ct)
    {
        var run = await _runRepository.GetRequiredAsync(runId, ct);

        run.Pause(_clock.UtcNow, reason);

        await _machineAdapter.PauseInspectionAsync(run.Id, ct);
        await _runRepository.SaveAsync(run, ct);
    }
}

Keep ViewModel separate

csharp
public sealed class RunControlViewModel : ObservableObject
{
    private readonly InspectionRunApplicationService _appService;

    public IAsyncRelayCommand StartCommand { get; }
    public IAsyncRelayCommand PauseCommand { get; }

    public RunControlViewModel(InspectionRunApplicationService appService)
    {
        _appService = appService;

        StartCommand = new AsyncRelayCommand(StartAsync);
        PauseCommand = new AsyncRelayCommand(PauseAsync);
    }

    private Task StartAsync()
        => _appService.StartRunAsync(CurrentRunId, CancellationToken.None);

    private Task PauseAsync()
        => _appService.PauseRunAsync(CurrentRunId, "Operator pause", CancellationToken.None);
}

The ViewModel is not deciding domain rules. It delegates.


12. Common mistakes

Mistake 1: modeling everything as DTOs

This happens because DTOs are easy.

The problem is that important rules get pushed elsewhere, and the model stops expressing meaning.

Production impact:

  • duplicated rules
  • inconsistent behavior
  • harder tests
  • brittle refactoring

Mistake 2: putting all rules in services

This usually comes from trying to be “clean” by keeping models passive.

The result is service sprawl and poor encapsulation.

Production impact:

  • giant services
  • hidden ownership
  • too much cross-method coupling
  • difficult onboarding for new engineers

Mistake 3: making domain objects huge god objects

Overreacting to anemic models can create the opposite problem.

A Recipe object should not know about repositories, UI prompts, machine SDKs, file paths, telemetry, and permission checks all at once.

Production impact:

  • tight coupling
  • difficult testing
  • hard persistence/integration boundaries

Mistake 4: forcing DDD terminology without benefit

Some teams spend more energy naming things “aggregates” and “bounded contexts” than clarifying the actual rules.

Production impact:

  • confusion
  • ceremony
  • resistance from the team
  • fake sophistication without clearer code

Mistake 5: too many abstractions around simple workflows

Not every action needs an elaborate domain hierarchy.

If something is basically a simple CRUD configuration record, do not build a miniature framework around it.

Production impact:

  • slower development
  • confusing indirection
  • fear of touching code

Mistake 6: over-modeling early

Early in a project, people may not understand the domain well enough to model it deeply.

Premature richness often encodes wrong assumptions too strongly.

Production impact:

  • painful rewrites
  • rigid model shape
  • team frustration

Mistake 7: under-modeling critical invariants

This is the opposite mistake and often more dangerous.

If machine safety rules, lifecycle rules, or finalization rules are not modeled clearly, bugs become operational problems.

Production impact:

  • invalid commands
  • inconsistent results
  • recovery issues
  • hard-to-debug production incidents

13. Trade-offs

There is no ideology that fits every part of the system.

Rich model vs simplicity

Rich models help when concepts are important and rule-heavy.

But not every object deserves behavior.

A report row should stay simple. An inspection run should not.

Explicit invariants vs flexibility

The more rules you enforce in the model, the harder it becomes to create temporary invalid states. That is usually good.

But some workflows genuinely require draft or incomplete states. In that case, model draft states explicitly instead of pretending the object is always fully valid.

Local behavior vs centralized services

Local behavior improves ownership and readability.

Centralized services still matter for orchestration, integration, and cross-object coordination.

The sweet spot is usually:

  • concept-local rules inside domain objects
  • process-wide coordination in application services

Domain purity vs practical integration

In industrial systems, pure separation is rarely perfect.

Machine SDKs, persistence, and UI timing constraints are real.

Do not force a fake purity that makes the code awkward. But do protect the core logic from being swallowed by infrastructure concerns.

Expressive design vs team familiarity

A beautiful model that the team cannot comfortably use is not a great production design.

Experienced engineers choose a level of modeling that improves correctness and understanding without creating a private architecture religion.


14. Refactoring toward a better domain model

You usually should not rewrite everything.

A safer approach is incremental.

Step 1: identify scattered rules

Look for rules repeated in:

  • ViewModels
  • service methods
  • validators
  • helpers
  • command handlers

Example: “run can only complete when all review-required items are resolved.”

If that rule appears in three places, it is a candidate for domain ownership.

Step 2: move behavior closer to the concept

Create or enhance the domain object with an explicit method:

csharp
run.Complete(summary, nowUtc, allReviewItemsResolved);

Then migrate callers to use it.

Step 3: reduce raw property mutation

Replace public setters with explicit methods.

Instead of:

csharp
run.Status = RunStatus.Paused;

move to:

csharp
run.Pause(nowUtc, reason);

Step 4: introduce value objects where confusion exists

If raw primitives are easy to misuse, introduce small value objects.

Good candidates:

  • coordinates
  • thresholds
  • dimensions
  • recipe versions
  • defect regions

This can often be done safely and gradually.

Step 5: keep orchestration outside

Do not move everything into the model.

The application layer should still coordinate:

  • loading
  • saving
  • SDK calls
  • retries
  • transactions
  • event publication

Step 6: write tests around behavior, not just data

Focus tests on invariants and transitions:

  • cannot start unvalidated recipe
  • cannot resume unless paused
  • cannot finalize defect without review metadata
  • cannot move stage before homing complete

These tests give confidence while refactoring.

Step 7: avoid risky big-bang rewrites

Refactor concept by concept:

  • first InspectionRun
  • then Recipe
  • then DefectClassification
  • then maybe MachineSession

That is safer and easier to validate with production behavior.


15. Senior engineer mental model

Experienced engineers do not ask, “Should we do rich domain models or not?”

They ask better questions:

  • What concepts carry important business meaning?
  • Where do bugs happen because rules are scattered?
  • Which invariants are too important to leave informal?
  • Which objects need lifecycle protection?
  • Which parts are just transport or reporting and should stay simple?
  • How do we make invalid behavior harder?
  • How do we keep the code understandable for the team?

That is the real mental model.

A strong domain model is not about making the codebase more intellectual. It is about making the important concepts of the system first-class in code.

In a wafer inspection system, those concepts are not abstract. They are things like:

  • recipe validity
  • run lifecycle
  • machine readiness
  • command safety
  • defect finalization
  • alarm severity
  • review completion

When these concepts are modeled clearly, the code becomes easier to reason about because it reflects the language of the system.

That is one of the biggest differences between average codebases and strong production systems.

In average codebases, the logic exists, but it is scattered.

In strong systems, the logic is easier to find because the important concepts have a real home.


Practical closing guidance

If I were reviewing a .NET codebase for this kind of system, I would not ask whether it uses DDD vocabulary.

I would ask:

  • Can I tell where run lifecycle rules live?
  • Can invalid state be created easily?
  • Are recipe rules centralized or duplicated?
  • Are ViewModels deciding domain truth?
  • Are services bloated because models are empty?
  • Are there clear value objects where primitives are ambiguous?
  • Does the code read like the system?

That is the standard that matters.

The best production domain models are not the most theoretical ones. They are the ones that make the important rules obvious, the invalid states harder to create, and the system easier to evolve under real pressure.

If you want, I can turn this into a second part focused only on interview-style Q&A, or a third part focused only on a full wafer inspection sample design with folders, classes, and boundaries.

Docs-first project memory for AI-assisted implementation.