Advanced C#: questions a senior asks automatically
Matteo Migliore

Matteo Migliore is an entrepreneur and software architect with over 25 years of experience developing .NET-based solutions and evolving enterprise-grade application architectures.

He has led enterprise projects, trained hundreds of developers, and helped companies of all sizes simplify complexity by turning software into profit for their business.

Andrea has seven years of C# experience.

Three web applications in production.

Last week the server went down on a Friday evening.

There was no error message.

There was no visible exception in the logs.

There was only a frozen application, fifty users blocked, and him on a call with the DBA looking for something that was not in the database.

The problem was not Andrea's competence.

It was the code that passes all tests and then fails in production: a set of assumptions that nobody wrote down because nobody ever thought to write them down.

Assumptions that hold as long as the system is small.

That break when concurrent requests double, when the table grows, when an external integration starts responding more slowly than expected.

There is something I need to tell you: it is not your fault.

The way you learn to build web applications in C# teaches you to write code that works.

It does not teach you to ask the questions that make problems visible before they arrive.

That you learn afterwards, the hard way, in production, at the worst possible moment.

What follows are the questions that separate applications that hold up from those that break down.

They are not advanced tricks.

They are the kind of question an experienced developer asks automatically while writing code, and that someone with the same experience but without that specific training does not yet know they should be asking.

Advanced C# means understanding why your system stops responding

You already know that threads in the server pool are a finite resource.

You already know that every concurrent HTTP request occupies one while it is being processed.

You know that if all threads are occupied, new requests wait.

And that is precisely why an async deadlock does not appear as an error: it appears as silence.

Most developers believe that using async operations in their code is sufficient to prevent this problem.

In practice the mechanism works only if the chain is continuous from the external operation all the way to the request entry point.

A single point that forces the synchronous result of an async operation can block the entire pool under concurrent load, without producing any error message.

In an ASP.NET Core application, the async mechanism exists to release the thread while waiting for external operations: database calls, responses from third-party APIs, file reads.

It releases the thread, which can then serve another request in the meantime.

This only works if the awaiting is done correctly.

There are two ways of obtaining the result of an async operation, and they produce radically different behaviour.

The first tells the thread to block until the operation completes: nothing else can use it in the meantime, and under certain synchronisation contexts this wait becomes permanent because the continuation cannot resume until the thread is free, but the thread is not free because it is waiting for the continuation.

The second tells the thread to return to the pool and be called back only when there is a result.

The visual difference in the code is minimal.

The difference in behaviour under fifty concurrent requests is the difference between an application that responds and one that locks without an error message.

The problem never appears locally, where simultaneous requests number three instead of fifty.

It appears in production, under real load.

The question to ask is not "do I use async operations?" but "is the chain continuous from the external operation to the request entry point, without any break in the middle?"

Every point that forces the synchronous result of an async operation is a potential break.

The number of such points in a medium-sized codebase is often higher than expected, because they accumulate over time, in different commits, by different developers, each convinced that in their particular case it was negligible.

A search through the code takes a few minutes: the list that emerges is usually longer than expected.

In libraries, not in applications, adding a specific configuration after each await avoids capturing the synchronisation context for the continuation.

In ASP.NET Core applications this is not necessary because the framework does not set one.

There is also the flip side: when you want to run multiple external calls in parallel, the wrong way is to await each one before starting the next.

That serialises them.

The right way is to start them all simultaneously, then await them all together.

On a hundred calls of 100ms each, the difference between the two approaches is between ten seconds and 100ms.

In an endpoint that aggregates data from multiple sources, that difference is visible to the user.

One limit exists: launching thousands of operations without control can saturate resources just as badly as the original problem.

The optimal number depends on the specific context.

Advanced C# programming and the copies slowing down your hot paths

Learning advanced C# means eliminating unnecessary copies from your code

Every operation that looks like a read actually produces a new object in memory.

Every new object is memory that must be reclaimed.

And that is precisely why the problem is not visible on a single operation: it becomes visible when that same operation is repeated a hundred thousand times per second.

Most developers believe that allocating temporary objects is a negligible cost.

In practice in hot paths, parsing every HTTP header, validating every field in every message from a queue, reading every record from a high-speed stream, the sum of those allocations creates pressure on the memory manager.

That pressure translates into execution pauses that arrive at moments you cannot control.

There is an alternative that creates no copies: instead of allocating, it returns a view over the same original memory, updating only a start pointer and a length.

The data does not move anywhere.

Extracting three different portions of a string with this approach produces not three objects but three references to the same string, each with its own window.

The constraint is precise.

This structure:

  • lives on the stack, not the heap
  • cannot be stored in a class field
  • cannot be captured by an anonymous function
  • cannot be used across an async operation

For cases where you need to cross those boundaries, there is a variant that maintains the same zero-copy semantics without the positional constraints.

This is not a tool for ordinary business code.

You use it where the cost of allocations becomes a concrete and measurable problem: not by intuition, not as a precaution, but after quantifying with a profiling tool that that specific path is the bottleneck.

Spotting an implicit copy in a constructed example is straightforward.

Spotting it in code you wrote yourself, on a real codebase with domain-named variables and a dependency chain you only half know, is a different kind of work.

In the C# training programme, we start from your codebase, not from exercises.

C# and the question you are not asking your database

The database knows how to filter.

It knows how to sort, aggregate, join.

It can do so across hundreds of millions of rows in a few hundred milliseconds, given the right indexes.

The web server hosting your API, on the other hand, has the RAM you configured and not a byte more.

And that is precisely why loading data into the process before filtering it is not a stylistic inefficiency: it is a structural problem that only becomes visible once the table has already grown.

Most developers believe that using an ORM guarantees that filtering happens on the database.

In practice it depends on a detail that is not visible at a glance: the type of the sequence you are working with.

As long as you operate on a type the ORM can translate into SQL, operations are executed by the database.

The moment that sequence becomes a different type, everything that follows happens in the application process.

Data crosses the network in full, occupies RAM, and what is not needed gets discarded.

The code reads as if it were filtering on the database.

It is not.

This is exactly what happened to Andrea.

On that Friday evening, the problem was not in the database.

It was in an endpoint calling a utility method that accepted the wrong type.

The transition from IQueryable to IEnumerable had occurred before the filter: the database had returned the entire table, three hundred thousand rows, to the process.

With ten simultaneous users and a small table, it had never been noticed.

With fifty users and a table that had grown over a year of production, the server ran out of memory without a comprehensible error message.

The comparison between the two types makes the scale of the problem clear:

IQueryableIEnumerable
Where filtering happensOn the database, using indexesIn the application process, in RAM
Data transferred over the networkOnly what satisfies the filterThe entire table
With small tablesIndistinguishableIndistinguishable
With large tablesStableOut-of-memory or severe slowdown

The transition happens every time you use a method the ORM cannot translate, or when you pass the sequence to another function in the wrong way.

With small tables, no visible problem.

Once the table has grown, the cost becomes apparent.

A separate but equally hidden issue: the same query may be executed twice if you enumerate it in two different places in the code.

The query is not executed when you define it: it is executed when you iterate the results.

Two iterations are two database round-trips.

Materialising the results the first time, when you know they will be needed more than once, avoids a free round-trip.

Beyond these two main problems, there are techniques that complete the picture.

Groupings with aggregated values: the question is not "do I know how to use GroupBy?" but "is this grouping happening on the database or in the process?"

Flattening hierarchical structures: a nested loop over a list that contains a list produces code that works but does not clearly express what it does; there is a way to express the same intent in a single declarative operation.

Reduction to a single value: when the result is a number computed over the entire sequence with specific logic, there is an operation that expresses it directly, without an accumulator variable and without a loop.

The code you know has costs you have not yet measured.

When you book the free consultation call, the first thing we do is look at your specific code, not a purpose-built exercise.

Working with .NET means working with tools that can mask these costs until the system reaches the size at which they become visible.

The BestDeveloper web development programme starts here: it identifies the concrete gaps in your codebase, then works directly on that.

No slides.

No abstract patterns.

The code you are already shipping to production.

Advanced C# training and the case the classic switch does not cover

You are adding a new case to an existing type.

You have updated all the implementations you can find.

You have shipped.

Three weeks later, in production, an error arrives that you cannot explain: a code path that does not handle that case because you did not know it was there.

And that is precisely why the missing-branch bug is one of the hardest to find: it does not exist until the data that reveals it exists.

Most developers believe that the classic switch and the chain of conditions are equivalent to an exhaustive selection expression, with the added flexibility of not needing to cover every case.

In practice that flexibility has a precise cost: the compiler cannot verify exhaustiveness.

The missing branch only surfaces at runtime, only in the specific case that is missing, often only in production.

A selection expression over a closed type, if it does not cover every possible case, produces an error before the build.

Not at runtime: at compilation.

Every time a new case is added to the type, the compiler flags all the points in the code that do not yet handle it.

You express an assumption (these are all the possible cases) and the development tool enforces it over time.

Every future change that adds a case must pass through all the points where that logic is applied: you cannot forget a case without it surfacing before the release.

The boundaries with the external world, including data from deserialisation, responses from third-party APIs, and user input, remain outside this protection: there, explicit checks are necessary regardless of what the type declares.

This approach protects the internal logic, not the data entry points.

Learning advanced C# and knowing when two identical instances are not equal in code

You have a dictionary.

You look up a key you know you inserted.

The dictionary does not find it.

You insert the key again.

Now there are two, visually identical, both in the dictionary.

The dictionary treats them as different because the code never defined what "equal" means for that type.

And that is precisely why this bug does not appear in tests: tests rarely compare separate instances with the same data.

The default answer for classes is: two objects are equal only if they are the same object in memory.

Two separate instances containing exactly the same data are not equal, unless you have explicitly written the code to say so.

For an entity with an identifier, this semantics is correct: if the ID changes, it is a different entity.

But for a type that represents a value, an address, a date range, a postal code, the correct semantics is the opposite.

Two objects with the same data are the same thing.

When you use a class for a type of this kind without defining equality, the behaviour you get is wrong relative to the type's semantics.

The result is subtle bugs that surface far from the cause: dictionaries that cannot find keys, sets with duplicates, comparisons that return wrong results.

Records solve this by construction: value equality is built in and correct.

They also provide a way to produce modified versions of an instance specifying only the fields that change, without mutating the original.

This operation eliminates a category of bugs that arise from shared state modified unintentionally.

For types that do have an identity, an account, an order, a session, the reference semantics of a class is the right choice.

There is also a variant that lives on the stack rather than dynamic memory: appropriate for very small, high-frequency types where reducing allocations has a measurable impact.

This kind of problem almost always passes undetected in code reviews, because the code looks correct.

It surfaces in production, when the behaviour diverges from expectations and the link back to the cause is already difficult to reconstruct.

Knowing where to look while writing is one of the things we work on in the C# training programme.

Advanced C# and the implicit constraint that becomes a bug a year later

An advanced C# training clarifies generic constraints and their implications

A generic method works.

Its original author knew the type had to implement a certain interface.

They did not write it in the signature.

A year later, a colleague uses that method with a different type.

The build passes.

The runtime behaviour is wrong in a non-obvious way.

And that is precisely why generic constraints are not a technical detail: they are documentation that the compiler enforces.

Constraints turn implicit assumptions into part of the method signature.

If the method requires the type to implement a certain interface, to be a reference type, to have a parameterless constructor, declaring it changes two things: callers receive a clear error if they do not meet the expectations, and readers understand the expectations from the signature itself instead of having to infer them from the implementation.

Every constraint you omit is an assumption that exists only in the head of the person who wrote the code.

In a codebase shared among multiple developers that evolves over time, that assumption is nowhere.

Constraints combine: you can simultaneously require that the type be a reference type, implement a certain interface, and have a parameterless constructor.

Some constraints are mutually exclusive.

Understanding how they combine lets you express precise expectations without unnecessary restrictions.

Have you ever seen an assignment error between generic types you could not explain?

It is usually variance.

A collection of more specific elements is not automatically assignable to a collection of the same type but with the base type: it depends on how the interface declared the variance of its type parameter.

Understanding this mechanism turns an incomprehensible error into a precise explanation in thirty seconds rather than an hour.

C# and what remains in memory when you close the window

An object's memory is reclaimed when there are no more active references to that object.

The object may be "closed" logically, out of scope, removed from the view: if a reference still exists somewhere in the code, it remains in memory.

And that is precisely why an event-based memory leak does not appear as an error: it appears as memory usage that grows over time, slowly, with no precise moment at which the problem began.

An event maintains internally a list of references to the objects subscribed to it.

As long as the event exists, those references exist.

If an object subscribes to an event of another object that outlives it, and never unsubscribes, it can never be reclaimed.

I saw this in an industrial monitoring application: every detail window subscribed to the data-update event of an application-level service.

The unsubscription was never written.

After eight hours of use, the process memory was four times its initial size.

The windows were visually closed, but they were never released in memory.

The application required a restart at every shift handover, and nobody knew why.

The problem was not in any log: it was in the absence of a line of code that had never been written.

The same dynamic recurs in web applications with persistent connections: a message handler on a hub, a listener on a notification channel, a handler on a distributed event system.

If the subscription happens when the connection opens and the unsubscription is never written, every closed connection leaves a reference alive.

With hundreds of connections per day, the result is the same.

Every subscription to an event needs a corresponding unsubscription at the point where the subscribed entity reaches the end of its lifecycle.

The problem is not writing the unsubscription: it is having a structured, mandatory place to put it.

Anonymous functions that capture external variables produce a similar effect for a different reason.

When such a function is created, it captures a reference to the external variable.

If that function outlives the object that created it, the reference remains alive.

There is no visible subscription point in the code.

The reference exists, occupies memory, and cannot be reclaimed as long as the function is alive.

Advanced C# and the exception handling that separates robust code from fragile code

You found the point that was throwing an exception.

You added a catch block.

The application no longer crashes.

Four weeks later you receive a report: the application is returning incorrect data and nobody knows since when.

And that is precisely why catching an exception is not handling it: it is shifting the problem in time and space, away from the cause.

Most developers believe that a catch block without a rethrow is a conservative and safe choice.

In practice it hides the signal that something went wrong.

The code that calls the failed operation does not know it failed.

It continues as if it had succeeded, with an internal state that does not match what the code assumes.

The result is an error that surfaces far away in time and space from the original problem, with no trace of what caused it.

The rule is direct: catch only what you know how to handle.

For everything else, let the problem propagate.

The application middleware knows how to handle it centrally: log it correctly, return the right HTTP response, notify the monitoring systems.

It is not each method's job to handle anything that might go wrong.

It is each method's job not to hide what it does not know how to handle.

The difference between the two approaches is not just a matter of style:

Catch and hideCatch and rethrow correctly
What the caller seesNothing: the operation appears to have succeededThe exception propagates to the middleware
Stack traceTruncated at the catch pointComplete back to the origin
Subsequent debugging"Wrong data" with no traceable causeException with a precise stack trace
Long-term riskSilent bug for weeksProblem visible immediately

When rethrowing a caught exception, preserving the original stack trace is not a stylistic preference: it is the difference between finding the problem in ten minutes and searching for three hours.

Rethrowing in the wrong way makes it appear as though the error originated at the catch point, not at the real point.

An exception filter lets you decide whether to enter the catch block without unwinding the stack: if the condition is false, the runtime looks for a handler higher up as if that block did not exist.

This lets you catch only the specific cases you know how to handle, letting everything else propagate.

A separate point: exceptions are not a flow control mechanism.

Validating invalid data with a comparison has a cost orders of magnitude lower than raising and catching an exception.

Advanced C# lessons and the value that looks like it is there but is not

A NullReferenceException does not tell you what was absent.

It tells you where you tried to use it.

The point where it is thrown is not the point where the problem was introduced: the absent value arrived earlier, perhaps from a call level above, perhaps from the deserialisation of a request.

And that is precisely why the crash always arrives at the wrong moment: because the problem was introduced much earlier.

Most developers believe the solution is to add null checks everywhere.

In practice the problem is not the missing check: it is not knowing where the absent value entered the system.

Enabling nullability annotations in the project shifts the analysis to before execution: static analysis traces the code flow and flags every path in which a variable declared non-nullable might nonetheless receive an absent value.

It does not change runtime behaviour: it changes when the problem surfaces.

The point that almost no resource makes clear: it is static analysis, not an absolute guarantee.

Data arriving from external sources, from deserialisation, from database queries, from non-annotated code, can contain null values at runtime regardless of what the types declare.

Annotations protect the internal logic.

They do not protect the boundaries with the external world: there, explicit checks are necessary in any case.

Using the operator that says "trust me on this value" mechanically to silence warnings means getting the opposite result from what the feature exists for.

Enabling it on an existing codebase is one of the few changes that produces visible results quickly: every warning that appears is an implicit assumption about nullability that had never been examined.

Not all of them are bugs.

But some are, and they are found without running the code.

The practical approach is to proceed one file at a time: a sustainable pace that produces concrete results without interrupting normal work.

These patterns share a common characteristic: the code signals nothing.

It compiles, runs, throws no exceptions.

The problem surfaces later, in a different context, when the connection between cause and effect is already difficult to trace.

Working on this kind of preventive reasoning, applied to the code you manage now, is at the centre of the C# training programme.

Learning C# and understanding how to measure the cost of every operation

Learning C# means measuring the cost of every operation before optimising

Every operation in the hot path has a cost.

Every allocated object is memory the system will have to reclaim.

Every reclamation happens in pauses you cannot control.

And that is precisely why wrong optimisations make things worse: you complicate code that was not the problem, leaving intact the code that was.

Concatenating strings with the concatenation operator inside a loop produces a new object per iteration.

In a path traversed a few times per second, invisible.

In an endpoint traversed a hundred thousand times per second, it shows up as variable and unpredictable latency at the worst moments.

Reducing allocations in critical paths does not mean avoiding object creation everywhere.

It means knowing where allocations have a measurable cost and applying specific tools there: a text buffer reused rather than recreated each time, pools of expensive-to-initialise resources, types that live on the stack rather than dynamic memory for small, short-lived objects.

The correct sequence is always: measure, identify, act.

Not the other way round.

Before the profiler, you do not know with certainty where the cost lies: you guess.

After the profiler, you know precisely.

"The second approach allocates 97% less" is a verifiable statement.

"It should be faster" is not.

Not all code should be optimised.

Business logic traversed dozens of times per day has completely different performance requirements from an endpoint traversed a million times per second.

Clarity about which code is a critical path and which is not is the prerequisite for any sensible intervention.

Advanced C# training: changing the questions you ask while writing code

Knowledge of these mechanisms has value only if it changes the code you write now, in the next code review, in the next endpoint added to an existing application.

What changes when these questions become a reflex is not stylistic.

It is not about syntax or formatting.

It is about what you look for while writing: not just "does it work?" but "does it hold up when the table has ten times the data?"

Not just "do the tests pass?" but "where will this object go after the HTTP request ends?"

Not just "is the code readable?" but "is there a case this logic does not handle that will arrive in production sooner or later?"

This kind of change is slow when done alone for a precise reason: you do not know what you do not know.

You do not know which questions you are not asking.

There is nobody reading the code and saying "this pattern, in this context, produces this kind of problem when the application scales."

You can work for years without encountering the situation that would make the problem visible, and in the meantime you keep building with the same implicit assumptions.

The BestDeveloper web development programme works like this: first a phase in which we look at the code you manage to identify where the concrete gaps are, then direct review and restructuring of that code.

No purpose-built exercises.

No slides with abstract patterns.

The code you are already shipping to production.

Results are measurable: application stability under load, code review quality, time between writing the code and discovering problems.

The programme is by application: not every profile and context is a good fit.

Advanced C#: questions from developers who want to find problems before production does

An advanced C# training teaches you to prevent bugs before they reach production

Not all of these problems depend on traffic volume.

The event-subscription memory leak manifests in any long-running application, regardless of the number of users.

The caught-and-hidden exception causes damage in any context.

The query pulling in excess data becomes problematic when the table exceeds a certain size, even with few users.

The thread pool runs dry under concurrent requests, not under low load.

Each mechanism has its own specific threshold, and that threshold is not always traffic volume.

The starting point, in your own codebase, is the problem you already have.

If the application is losing memory over time, start with event subscriptions that are never removed and anonymous functions capturing variables.

If it stops responding under load, start with the async operation chain.

If there are slow queries you cannot explain, start with the boundary between database operations and in-memory operations.

If there are no visible problems, rereading the code with these questions in mind is the way to find them before they surface.

The ideal context for learning all of this is already working full-time on a real web application.

Real code has the nuances and interdependencies that exercises can only partially simulate.

The obstacle is not time: it is having the right questions at the right moment, while you are still writing the code, rather than three months later when the application shows the problems in production.

Without an external reference to bring them, those questions only arrive after you have seen the problem.

That is why the BestDeveloper web development programme is not a course with slides and quizzes.

The first thing we do is look at the code you manage now: we identify the concrete gaps in your application, plan the work based on what yields the highest return in your specific context, and work on that.

Results are measurable: application stability under load, code review quality, time between writing and discovering problems.

The programme is by application because not every profile and context is a good fit.

A free 30-minute call is used to assess whether it is right for you.

In that half hour we look together at your specific context: where the solid points are, where the concrete gaps are, whether the kind of work done in the programme makes sense for your situation.

If it does not make sense, we say so plainly.

The results that appear first are in code reviews: after the first few weeks, problems start surfacing that previously went unnoticed.

Results in application stability take longer because they depend on real load and the frequency with which certain paths are traversed.

There are no universal numbers: every codebase has its own priority gaps.

Andrea resolved the Friday evening problem.

It was not in the database.

It was in a point in the async chain where the thread was being blocked rather than released.

Eight lines of code.

Two hours of debugging that, with this question in mind while writing the code, would never have become a production incident.

He now knows where to look first.

He knows the questions that his original training never taught him, the ones we teach in the C# training programme.

The codebase you manage now almost certainly has some of these implicit assumptions.

That is not a criticism: it is the result of how one learns to build applications in most training paths, which teach the features of the language and the framework without teaching the questions to ask while using them.

The consequences are not visible until the application reaches the size at which the assumptions stop holding.

The distance between "the code looks fine" and "the problem is visible" can be months or years.

During that time you keep shipping with the same patterns, doing the same code reviews that do not catch the problems because nobody knows where to look.

Then the application grows, and the assumptions all become visible at once, usually at an inconvenient moment.

The programme is by application: we work directly on each codebase and not every context is a good fit.

When you book the call, the first thing we will do is look at the code you manage right now.

If it makes sense to proceed, we start.

If it does not make sense, I will tell you plainly.

Frequently asked questions

They are different concepts that are often confused. async/await is about efficiency while waiting for I/O-bound operations (HTTP calls, database queries, file reads): when you await, the thread is not blocked waiting but is returned to the thread pool to serve other requests, and the method resumes when the operation completes, without involving new threads. Parallelism, on the other hand, is about running CPU-bound work concurrently across multiple threads (typically with Task.Run, Parallel.ForEach or PLINQ). Using async for CPU-intensive work brings no benefit and often makes things worse. The practical rule: async for I/O, parallelism for computation. Confusing the two is the most common conceptual error among those who have not mastered advanced C#.

When a SynchronizationContext is present (typical in classic ASP.NET, WPF and WinForms applications), the thread calling .Result or .Wait() stays blocked waiting for the Task to complete. But the continuation of the async method, after the await, needs exactly that context to resume: the blocked thread cannot run it and the task never completes. They block each other. The correct solution is the async all the way principle: if a method calls async code it must be async itself, up to the application entry point. In libraries, using ConfigureAwait(false) reduces the risk because it avoids capturing the context.

Span is worth using when you need to work on slices of existing data without allocating copies on the heap: high-volume parsing, network buffer manipulation, string processing in hot code paths. Slicing a Span allocates nothing, unlike Substring or Skip().Take() which create new objects. It is not an everyday tool: in normal business logic it brings no measurable benefit and adds complexity because of its constraints (it is a ref struct, lives only on the stack, cannot be used across an await nor captured in a lambda). When you need the same semantics but with the ability to survive on the heap, you use Memory. The rule: measure first with BenchmarkDotNet, and introduce Span only where allocations are a real problem.

It is one of the most important distinctions in advanced C# when working with Entity Framework. As long as a query is IQueryable, the LINQ operators you apply are translated into SQL and executed by the database: filters, projections and orderings run server-side, and only the requested data reaches memory. The moment the query becomes IEnumerable (for example by calling AsEnumerable() or ToList()), all subsequent code runs in memory in your process. The concrete risk: using a custom method inside a Where on IQueryable that EF cannot translate may make the query fail or, worse, download the entire table into memory before filtering. Knowing exactly where the boundary between database and process lies is a senior-level skill.

No, and understanding this is essential. Nullable reference types are a static analysis that happens at compile time: the compiler tracks the flow and warns you when you dereference something that might be null without having checked it. But they add no runtime checks. This means data coming from external sources (JSON deserialization, database queries, non-annotated library code, reflection) can still be null despite the type declaring otherwise. The null-forgiving operator (!) should be used sparingly because it tells the compiler to trust without verifying. Nullable reference types dramatically reduce NullReferenceExceptions by moving the problem from runtime to compile time, but do not eliminate it entirely: checks at the system boundaries remain essential.

Records are worth it when you need a type with value equality and immutability without writing boilerplate. Two records with the same values are considered equal (a == b returns true), whereas two instances of a class are equal only if they are the same reference in memory. This is exactly the semantics you want for Value Objects in Domain-Driven Design, for DTOs, for messages and for any data that represents a value rather than an identity. Records automatically generate Equals, GetHashCode, ToString and support the with expression to create copies changing only some fields, without mutating the original. Use a class when your object has its own identity that persists even if its data changes (typically domain entities with an Id), and a record when what matters is the value.

Leave your details in the form below

Matteo Migliore

Matteo Migliore is an entrepreneur and software architect with over 25 years of experience developing .NET-based solutions and evolving enterprise-grade application architectures.

Throughout his career, he has worked with organizations such as Cotonella, Il Sole 24 Ore, FIAT and NATO, leading teams in developing scalable platforms and modernizing complex legacy ecosystems.

He has trained hundreds of developers and supported companies of all sizes in turning software into a competitive advantage, reducing technical debt and achieving measurable business results.

Stai leggendo perché vuoi smettere di rattoppare software fragile.Scopri il metodo per progettare sistemi che reggono nel tempo.