Null Propagation in C#: When Contracts Become Optional
Null propagation is one of those C# features that feels obviously helpful.
var city = customer?.Address?.City;
The code is short, avoids the obvious NullReferenceException, and handles uncertainty without ceremony. In the right place, that is exactly what you want.
But this line also hides a question that matters more than the syntax suggests:
What did null mean?
It might mean there was no customer. It might mean the customer had no address. It might mean the address had no city. If all of those states are intentionally equivalent, the expression is fine. If they are not equivalent, the expression has collapsed information the rest of the system may still need.
That is the design problem this article is interested in. Null propagation is not bad; it is a useful language feature. But when it is used to extract backend business data, it can turn different contract failures into the same quiet null.
In service-to-service code, that can become a client-side coping mechanism for weak contracts. The producer does not clearly say what it guarantees, the consumer uses ?. and ?? to keep moving, and contract uncertainty becomes normal application code.
TL;DR
?.is most defensible when the missing state has a deliberate meaning.- In backend code, its strongest use case is often optional actions: if the receiver exists, do the thing; otherwise do nothing.
- It is much more suspicious when used to extract business data.
- Null propagation does not validate data. It collapses missing states.
- Weak DTO contracts plus
?.can make consumers tolerate ambiguity instead of challenging the producer contract. - Nullable collections are a common example:
nulloften adds no useful meaning beyond empty. - Validate, normalize, or reject uncertainty at the boundary; map into stronger internal models.
What Null Propagation Actually Does
The null-conditional operator short-circuits member access when the receiver is null:
var city = customer?.Address?.City;
If customer is null, the expression evaluates to null. If customer.Address is null, it also evaluates to null. If both exist, City is read.
The important distinction is what the operator does not do. ?. decides what an expression produces when something in the chain is missing. It does not decide whether the missing data is valid; that is a contract and validation question.
Optional Actions Are Different From Optional Data
One of the cleanest backend uses of null propagation is not property access at all. It is optional invocation:
PropertyChanged?.Invoke(this, args);
This is the kind of example shown in the Microsoft documentation for null-conditional operators. It works because the meaning is clear:
If there are subscribers, notify them. If there are no subscribers, do nothing.
There is no nullable value to interpret later. No downstream code has to ask whether the missing value meant “not loaded”, “invalid”, “not applicable”, or “producer forgot to send it.” The absence of a subscriber simply means there is no optional action to perform.
The same shape can appear in backend code with optional callbacks, observers, or instrumentation:
progress?.Report(percentComplete);
onCompleted?.Invoke(result);
activity?.SetTag("order.id", orderId);
These examples are not extracting business data. They are saying: if the optional collaborator exists, perform a side effect. Otherwise, continue.
That is very different from extracting business data:
var countryCode = order.Customer?.Address?.CountryCode;
Now the code has produced data, and the data is nullable. The next part of the system still has to decide what that null means:
- order has no customer
- customer has no address
- address has no country code
If those states intentionally mean the same thing in this context, the collapse may be fine. Usually they deserve different treatment.
The interesting question is not whether ?. is good or bad. The question is whether the collapsed null has a deliberate meaning.
The Contract Hidden in Ordinary Code
Consider ordinary business logic:
var total = order.Lines.Sum(line => line.Quantity * line.UnitPrice);
This code says more than it first appears to say.
It says:
orderexists.order.Linesexists.- Each line has the data required to calculate a total.
- The caller is allowed to rely on those facts.
That is a contract.
Not necessarily a formal contract with a schema file, an OpenAPI document, or a generated client. It is still a contract. The code is written against assumptions that must be true for the behavior to make sense.
Now compare it with this:
var total = order?.Lines?
.Sum(line => line.Quantity * line.UnitPrice);
The code is shorter than explicit null checks, but the meaning is weaker. It says:
- Maybe there is an order.
- Maybe the order has a
Linescollection. - Maybe we can calculate a total.
- If not, null is an acceptable result here.
And the decision is not over. The next piece of code still has to decide what total is null means:
if (total is null)
{
// Missing order? Missing lines? Optional calculation?
}
Sometimes that is exactly right: the whole order may be optional in this context, or the calculation itself may be optional. But an order draft with zero line items is usually better represented by an empty Lines collection than by a null one.
If an order without Lines is invalid in this part of the system, the null-propagating version is not more robust. It is less direct. It turns a broken assumption into another nullable value that someone else must interpret.
The contract did not disappear. Its enforcement was deferred.
Service Contracts and the Client-Side Escape Hatch
This becomes more painful in service-to-service communication.
Imagine a consumer deserializes this response DTO:
public sealed class OrderDto
{
public string? Id { get; init; }
public CustomerDto? Customer { get; init; }
public List<OrderLineDto>? Lines { get; init; }
}
There is nothing wrong with a DTO representing the raw shape of a service response. The problem starts when that raw shape becomes the model the rest of the application has to reason about, either directly or through a mechanical mapping into an equally nullable Order.
There may be no malicious intent here. The team may simply have forgotten to mark fields as required; the OpenAPI schema may have been generated from loose server models; the client may have been generated from a schema where every property is optional by default; or backward compatibility may have trained everyone to make every field optional “just in case.”
The result is the same: the producer has exported uncertainty.
On the consumer side, the code now tends to look like this:
var orderDto = await ordersClient.GetOrderAsync(orderId);
var order = orderMapper.Map(orderDto); // Customer? and Lines? survive the mapping.
var customerId = order.Customer?.Id;
var activeLines = order.Lines?
.Where(line => line.IsActive)
.ToList() ?? [];
The DTO is no longer visible, but its uncertainty is still there. The code looks defensive, and it is organizationally convenient: the consumer does not have to go back to the producer and ask:
- Is
Customerrequired? - Are
Linesrequired? - Can
Linesbe null, or only empty? - What does a missing
Idmean? - Is this order payload valid without these fields?
Instead, the consumer absorbs the ambiguity.
That is the real cost. Null propagation can make weak contracts easier to tolerate, which means teams are less likely to challenge the contract itself.
The failure path becomes quiet. The producer never feels much pressure to tighten the contract, the consumer keeps shipping, and the uncertainty becomes part of the client codebase.
Weak Contracts Spread
A weak service contract does not stay at the service boundary. It leaks into every consumer that accepts it as normal.
Suppose the client maps the DTO into an internal Order, but keeps the same nullable shape:
var customer = order.Customer;
if (customer?.Address?.CountryCode == "NO")
{
ApplyNorwegianVatRules(customer);
}
The business rule is now mixed with contract doubt. The code is not only asking whether the customer is in Norway. It is also asking whether the customer exists, whether the address exists, and whether the country code exists.
Those are different questions.
One question belongs to business logic: is this customer in Norway? The others belong to contract validation: did the producer send the customer, address, and country code this consumer requires?
That separation may look less elegant at first, but it says something important. If required data is missing, the response is invalid. We should not pretend the business rule simply evaluated to false.
Failing fast is not about enjoying exceptions. It is about extracting meaning at the boundary and preserving it inside the system.
If the producer sent an invalid response, that is a contract problem. If the customer is not in Norway, that is a business fact. Null propagation can make those two situations look too similar.
The Nullable Collection Problem
Collections make the cost especially easy to see, because a collection already has a natural way to say “nothing here”:
{
"lines": []
}
This is precise: the collection is present, and it has no items.
When the contract also allows null, the consumer gets a second absence-like state:
{
"lines": null
}
And if the property is not required, the contract may allow a third:
{}
Now the consumer has to decide whether the absence-like states are meaningfully different:
- Missing property.
- Null collection.
- Empty collection.
Sometimes that distinction is real. Maybe null means “not requested”, while empty means “requested and no items exist.” If the contract says that clearly, null is carrying information.
But in most service-to-service DTOs I have seen, a null collection does not carry that kind of meaning. It does not say “there are no items” better than an empty array does. It usually says something less useful: the producer did not initialize the property, the schema did not declare the property as required, or the consumer cannot tell whether absence is meaningful.
That ambiguity becomes consumer work. The client now has to choose between normalizing:
var lines = order.Lines ?? [];
or defending everywhere:
foreach (var line in order.Lines ?? [])
{
// ...
}
or propagating uncertainty:
var total = order.Lines?
.Sum(line => line.Quantity * line.UnitPrice);
All three choices may be reasonable at a boundary. They are much less attractive when repeated throughout application logic, because each repetition makes a local decision about something the producer should have specified once.
The exact code depends on your serializers, schema generation, and compatibility rules. The design principle is simpler:
If null and empty do not mean different things, do not make every consumer handle both.
The Cost of Quiet Nulls
Null propagation can make code look branchless, but the behavior still depends on combinations of missing values:
var label = order.Customer?.Address?.City ?? "Unknown";
This reads as one line, but it represents several states: no customer, no address, no city, or a real city. A local complexity metric may not notice much, but the system may now accept more states than it needs.
A null-propagating chain does not validate the payload; it converts several possible shapes into one result. That may be intentional, but when it is accidental, the missing value becomes less observable.
The same concern shows up outside C#. JavaScript has optional chaining, and ESLint has a no-unsafe-optional-chaining rule because a chain can make one access safe while the surrounding expression still assumes a value. Different language, same warning: optional access is not validation.
That extra state space shows up in tests. If weak response shapes leak into internal models, ordinary business tests have to account for missing customer, missing address, missing lines, and maybe null line values. Some paths may be legitimate. Others exist only because the DTO contract was weak. Adapter or mapper tests should cover invalid response shapes; business tests should usually work against the stronger model.
It also shows up in debugging. If a boundary rejects a response with Order response must include Lines, the failure points back to the producer contract. If the same missing Lines value is propagated and later becomes CheckoutDecision.ManualReview, the system has turned a contract violation into a business decision. Strong contracts tend to fail early; weak contracts tend to fail late, and ?. can make late failure look tidier than it is.
The Boundary Principle
The practical rule is simple:
Validate uncertainty at the edges. Keep stronger contracts in the core.
At the boundary, be suspicious:
var response = await ordersClient.GetOrderAsync(orderId);
if (response is null)
{
throw new InvalidContractException("Order response is required.");
}
if (response.Customer is null)
{
throw new InvalidContractException("Order response must include Customer.");
}
if (string.IsNullOrWhiteSpace(response.Id))
{
throw new InvalidContractException("Order response must include Id.");
}
if (response.Lines is null)
{
throw new InvalidContractException("Order response must include Lines.");
}
Then map into an internal model that represents what the application is allowed to rely on:
var order = orderMapper.MapValidated(response);
After that, business logic should not need to keep re-litigating whether the producer honored the contract:
var total = order.Lines.Sum(line => line.Total);
This is not about trusting all data blindly. It is the opposite: deciding where validation belongs. Boundary code is allowed to be defensive; core code should be allowed to be confident.
Nullable Reference Types Are Not the Contract
Nullable reference types help. They make uncertainty visible in code:
public CustomerDto? Customer { get; init; }
That annotation is useful. It tells the reader and compiler that Customer may be null.
But nullable reference types do not decide whether Customer should be nullable. They expose uncertainty; they do not design the contract.
The same is true for required members:
public required CustomerDto Customer { get; init; }
This improves the local C# model. It can also help schema generation when the tooling is configured properly. But it is still only one part of the contract story. Serialization settings, OpenAPI generation, JSON schema, backward compatibility, and producer behavior all matter.
Representation is the easy part. The contract decision is whether the consumer should be forced to handle this property as nullable.
If the answer is no, the contract should say so.
When Null Propagation Is a Good Fit
I reach for null propagation when the missing state has a deliberate meaning.
It is usually a good fit for optional actions:
PropertyChanged?.Invoke(this, args);
progress?.Report(percentComplete);
onCompleted?.Invoke(result);
These calls do not produce business data that must be interpreted later. If the optional collaborator exists, the action happens. If it does not, nothing happens.
Presentation code is a narrower case. A projection may intentionally collapse several missing states into “nothing to render”, but that decision should belong to the projection itself. I would not use that as a general backend argument for ?.; once the value carries business meaning, branch or validate instead.
I become much less comfortable when ?. appears in:
- business rules
- validated domain models
- service-client code after contract validation
- collection handling where empty would be enough
- deep object graphs inside application logic
- any value extraction where the missing states are not equivalent
That discomfort is not a call for nested null checks. It is a prompt to make the contract decision explicit.
Robert C. Martin makes a related point in his discussion of programming paradigms: discipline often means choosing not to use every capability a language gives you. Applied narrowly here, ?. is useful, but it should not make invalid contracts comfortable.
The review question I use is simple:
Are these missing states intentionally equivalent?
If the answer is yes, null propagation may be a clean expression of the model.
If the answer is no, I prefer to branch, validate, normalize, or map into a stronger type.
If the contract is external and cannot be fixed, I still try to contain the uncertainty near the adapter. Once the data leaves that adapter, the rest of the application should not have to keep asking whether the external service behaved.
Conclusion
Null propagation is risky not because it exists, but because it makes ambiguity cheap. In service-to-service code, that matters: once weak contracts are comfortable to consume, they stop being pressure points.
Use ?. where absence has a deliberate meaning. Validate, normalize, or reject it where absence is a contract question.
Convenience is valuable. A strong contract is more valuable.

RSS - Posts