DEV Community

Cover image for I’ve Written C# for 10+ Years. These Are the Fundamentals I Still Revisit.
Ayman Atif
Ayman Atif

Posted on

I’ve Written C# for 10+ Years. These Are the Fundamentals I Still Revisit.

After more than 10 years of writing C#, I still look things up.

Not obscure reflection APIs.

Not some forgotten compiler flag.

Fundamentals.

The difference between const and readonly.

What exactly happens when I pass a reference type to a method.

Whether a LINQ query has actually executed yet.

What lifetime a dependency should have.

What happens to an exception inside an asynchronous operation.

Things I learned years ago.

Early in my career, I thought becoming an experienced developer meant eventually reaching a point where these questions disappeared.

The opposite happened.

The longer I worked with production software, the more I realized that a surprising number of difficult bugs come down to simple concepts behaving exactly as designed.

You just misunderstood the design.

I’m Ayman Atif, a software engineer with more than 10 years of experience building business software, including CRM systems, financial applications, APIs, automation tools, reporting systems, and other production applications.

C# and .NET have been a major part of that journey.

You can see some of the software I’ve worked on and more about my background on my portfolio:

Ayman Atif — Software Engineer

These are some of the C# fundamentals I still revisit.

Not because I forgot how to write C#.

Because understanding them properly matters much more once the code has to survive production.

1. Value Types vs. Reference Types

Every C# developer learns this fairly early.

Value types contain their value.

Reference types contain a reference to an object.

Easy.

Until code like this appears:

var customer = new Customer
{
    Name = "John"
};

UpdateCustomer(customer);

Console.WriteLine(customer.Name);

void UpdateCustomer(Customer customer)
{
    customer.Name = "Ayman";
}
Enter fullscreen mode Exit fullscreen mode

The output is:

Ayman
Enter fullscreen mode Exit fullscreen mode

That surprises almost nobody with C# experience.

But change the method slightly:

void UpdateCustomer(Customer customer)
{
    customer = new Customer
    {
        Name = "Ayman"
    };
}
Enter fullscreen mode Exit fullscreen mode

Now the original object hasn't been replaced.

Why?

Because the reference itself was passed by value.

The method received a copy of that reference.

Both references initially pointed to the same object. Reassigning the local copy doesn't change the caller's variable.

That distinction sounds academic when you're learning C#.

It stops being academic when you're debugging mutation across a large application.

The useful question isn't simply:

Is this a class or a struct?

It's:

What exactly is being copied, and what exactly can this code mutate?

That question has saved me considerably more time than memorizing definitions.

2. const vs. readonly

This is another one that looks almost too basic to revisit.

public const int MaxRetries = 3;
Enter fullscreen mode Exit fullscreen mode

versus:

public readonly int MaxRetries;
Enter fullscreen mode Exit fullscreen mode

A const value is known at compile time and is implicitly static.

A readonly field can be assigned when declared or inside a constructor.

That means this is perfectly valid:

public class RetryPolicy
{
    public readonly int MaxRetries;

    public RetryPolicy(int maxRetries)
    {
        MaxRetries = maxRetries;
    }
}
Enter fullscreen mode Exit fullscreen mode

But there's another reason I pay attention to const, especially when libraries or multiple assemblies are involved.

Constants can effectively be embedded into consuming code at compile time.

Change a public constant in one assembly and an already-compiled consumer may continue using the old value until it is recompiled.

That's the kind of detail that doesn't matter much in a console exercise.

Across independently deployed components, it can matter a lot.

The fundamental didn't change.

The context did.

3. var Is Not dynamic

I still see these mentally grouped together because both can make the declared type less obvious when reading a line of code.

But they solve completely different problems.

var customer = GetCustomer();
Enter fullscreen mode Exit fullscreen mode

The compiler still knows the type of customer.

This:

dynamic customer = GetCustomer();
Enter fullscreen mode Exit fullscreen mode

is different.

Certain type checks are deferred until runtime.

With var, this fails at compile time:

var number = 10;
number = "ten";
Enter fullscreen mode Exit fullscreen mode

With dynamic, this is allowed:

dynamic number = 10;
number = "ten";
Enter fullscreen mode Exit fullscreen mode

That flexibility comes with a tradeoff.

You are moving some protection from compile time to runtime.

var is mostly about letting the compiler infer a type.

dynamic changes how operations on that value are resolved.

Small distinction.

Very different consequences.

4. ref, out, and in

These keywords are easy to memorize and surprisingly easy to become fuzzy about when you don't use them regularly.

I periodically revisit them.

ref passes a variable by reference and expects it to already be initialized.

void Increment(ref int number)
{
    number++;
}
Enter fullscreen mode Exit fullscreen mode

out also passes by reference, but the called method must assign a value before returning.

bool TryGetAge(string input, out int age)
{
    return int.TryParse(input, out age);
}
Enter fullscreen mode Exit fullscreen mode

in passes an argument by reference while preventing the method from assigning to the parameter.

void Process(in LargeStruct data)
{
    // data cannot be reassigned here
}
Enter fullscreen mode Exit fullscreen mode

The syntax isn't the interesting part.

The interesting part is recognizing what these keywords communicate about ownership, mutation, and intent.

Most code doesn't need them.

That's exactly why they're worth revisiting before using them casually.

5. LINQ: When Does This Code Actually Run?

LINQ is one of my favorite parts of C#.

It is also very easy to write code that looks simpler than what it actually does.

Consider:

var activeCustomers = customers
    .Where(x => x.IsActive);
Enter fullscreen mode Exit fullscreen mode

Has the filtering happened?

Not necessarily.

Many LINQ operations use deferred execution.

The query may execute when you enumerate it:

foreach (var customer in activeCustomers)
{
    Console.WriteLine(customer.Name);
}
Enter fullscreen mode Exit fullscreen mode

Or when you materialize it:

var result = activeCustomers.ToList();
Enter fullscreen mode Exit fullscreen mode

That difference becomes much more important with Entity Framework Core.

This:

var customers = context.Customers
    .Where(x => x.IsActive);
Enter fullscreen mode Exit fullscreen mode

isn't the same thing as immediately loading every matching customer into memory.

You're building a query.

Then something like:

var customers = await context.Customers
    .Where(x => x.IsActive)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

causes it to execute against the database.

When debugging LINQ or EF Core code, one of my first questions is often:

At what point does this query actually execute?

Then:

How many times does it execute?

Those two questions can expose some nasty performance problems.

6. async and await Don't Mean "Create Another Thread"

This is probably one of the most important C# fundamentals to understand properly.

I used to mentally associate asynchronous programming much more closely with threads.

That's an easy mental model to fall back on.

But:

await httpClient.GetAsync(url);
Enter fullscreen mode Exit fullscreen mode

does not simply mean:

Create another thread and make it wait for the HTTP request.

For I/O-bound work, the whole point is that a thread doesn't need to sit there blocked while the external operation completes.

When the operation finishes, execution can continue.

That's incredibly important for server applications.

Imagine an ASP.NET Core API handling hundreds of requests that are all waiting for databases, HTTP APIs, file operations, or other I/O.

Blocking threads while they wait can eat up resources quickly.

Asynchronous I/O allows those resources to be used more effectively.

But there is another lesson here:

Making a method async doesn't magically make the application scalable.

If you write:

var result = SomeAsyncOperation().Result;
Enter fullscreen mode Exit fullscreen mode

or:

SomeAsyncOperation().Wait();
Enter fullscreen mode Exit fullscreen mode

you are blocking again.

Understanding why async works matters much more than knowing where to type await.

7. IDisposable and using

I learned using a long time ago.

I appreciate it much more now.

using var stream = new FileStream(
    path,
    FileMode.Open);
Enter fullscreen mode Exit fullscreen mode

When the scope ends, Dispose() is called.

Simple.

But the underlying lesson is about resource ownership.

Some objects represent resources that shouldn't simply be left for garbage collection to eventually deal with.

Files.

Streams.

Database-related resources.

Network resources.

Native handles.

And plenty of other things.

Modern C# makes the syntax extremely clean:

using var connection = CreateConnection();
Enter fullscreen mode Exit fullscreen mode

The syntax is almost boring.

The responsibility behind it isn't.

When working with something disposable, I want to know:

Who owns this resource?

Who is responsible for disposing it?

That becomes especially important once dependency injection enters the equation.

8. Dependency Injection Lifetimes

If you work with ASP.NET Core, you've probably seen these:

services.AddTransient<IService, Service>();
services.AddScoped<IService, Service>();
services.AddSingleton<IService, Service>();
Enter fullscreen mode Exit fullscreen mode

It's tempting to memorize them as:

Transient = always new
Scoped = once per request
Singleton = one
Enter fullscreen mode Exit fullscreen mode

That's a useful starting point.

It isn't enough.

The real question is:

How long should this object live, and what does it depend on?

A singleton depending on state that belongs to an individual request should immediately make you suspicious.

A service holding mutable shared state needs careful thought.

An Entity Framework DbContext has lifetime expectations of its own.

These problems often don't look dramatic in code.

The registrations might occupy three lines in Program.cs.

The bug can appear somewhere completely different.

That's why I revisit service lifetimes instead of assuming that because I've used dependency injection for years, every lifetime decision is obvious.

9. Entity Framework Core Tracking

EF Core makes database work pleasantly simple.

Sometimes too simple.

You can write:

var customers = await context.Customers
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

and move on.

But what is EF Core doing with those entities afterward?

By default, queries returning entities are generally tracking them.

That's useful when you intend to modify them and save the changes.

If you're loading data purely for reading, tracking may be unnecessary.

That's where something like this becomes useful:

var customers = await context.Customers
    .AsNoTracking()
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

I'm not suggesting throwing AsNoTracking() onto every query.

That would just replace one habit with another.

The point is understanding what the framework is doing for you.

Abstractions are useful because they hide complexity.

Experienced developers still need to know enough about the hidden complexity to recognize when it matters.

10. Exceptions: Catching Everything Isn't Defensive Programming

This looks safe:

try
{
    ProcessOrder();
}
catch (Exception)
{
}
Enter fullscreen mode Exit fullscreen mode

It isn't.

You've taken an error and made it invisible.

A slightly more sophisticated version isn't necessarily much better:

try
{
    ProcessOrder();
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);
}
Enter fullscreen mode Exit fullscreen mode

What happens now?

Can the application continue safely?

Should the operation be retried?

Should the caller know it failed?

Should it be logged?

Is this actually the correct layer to handle the exception?

Catching an exception should have a reason.

Sometimes the correct decision is to handle it.

Sometimes it's to translate it into something meaningful at an application boundary.

Sometimes it's to log it.

And sometimes the correct decision is to let it propagate.

I don't base exception handling on:

Can this throw?

I prefer:

Can this layer do something meaningful if it throws?

That produces very different code.

11. Collections Are Part of Your Design

Need several objects?

Use List<T>.

That's often perfectly fine.

But not always.

If you're repeatedly checking whether something exists:

if (items.Contains(id))
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

then the choice between something like a List<T> and a HashSet<T> can become important as the collection grows.

If you're constantly retrieving values by key, a Dictionary<TKey, TValue> may better represent the problem.

The data structure communicates something about the operation you care about.

A list says sequence.

A dictionary says lookup by key.

A set says uniqueness and membership.

You don't need to obsess over theoretical complexity every time you create a collection.

But once data grows or an operation sits inside a hot loop, choosing the right structure can change the equation considerably.

12. static Deserves More Thought Than It Gets

static looks harmless.

Sometimes it is exactly what you need.

But static state can quietly create coupling.

Consider:

public static class CurrentUser
{
    public static int Id { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

That might look convenient in a tiny application.

Now introduce concurrent users.

Tests.

Parallel execution.

Different request contexts.

Suddenly that convenience needs much more scrutiny.

This doesn't mean static members are bad.

Far from it.

Pure utility functionality can make perfect sense as static behavior.

Constants and genuinely application-wide state have legitimate uses too.

The important distinction is between:

functionality that doesn't require instance state

and

mutable global state that happens to be convenient.

Those are not the same thing.

13. Equality Is More Complicated Than ==

This is another fundamental that becomes interesting once domain models become more complicated.

What does it mean for two objects to be equal?

var customer1 = new Customer
{
    Id = 10,
    Name = "Ayman"
};

var customer2 = new Customer
{
    Id = 10,
    Name = "Ayman"
};
Enter fullscreen mode Exit fullscreen mode

Are these equal?

They contain the same values.

But they're two separate instances.

Depending on the type and how equality is implemented, your answer may differ.

Then records enter the picture:

public record Customer(int Id, string Name);
Enter fullscreen mode Exit fullscreen mode

Records give you value-oriented equality semantics that can be extremely useful for certain models.

The broader lesson isn't "use records."

It's to define what equality actually means for the thing you're modeling.

Two references being different doesn't necessarily mean the domain considers the values different.

And two references pointing to the same object doesn't necessarily tell you anything useful about business identity.

14. Nullability Is Better When You Treat It as Design Information

Nullable reference types were one of those C# additions that initially looked like extra compiler noise to some developers.

But consider:

string name;
Enter fullscreen mode Exit fullscreen mode

versus:

string? name;
Enter fullscreen mode Exit fullscreen mode

That ? communicates something.

The absence of a value is expected.

That's not merely a compiler detail.

It's information about your model.

If a customer must always have an email address, model that intentionally.

If a middle name is optional, model that intentionally too.

The goal isn't to silence every nullable warning as quickly as possible.

The goal is to make the compiler help you identify places where your assumptions about missing values may be wrong.

The more accurately the type system describes reality, the less defensive guessing the rest of your code needs.

The Pattern I Keep Seeing

After years of C# and .NET development, I've noticed something.

Junior developers often worry about not knowing enough advanced features.

Experienced developers often worry about misunderstanding simple ones.

That's a very different concern.

A clever language feature might save you five lines of code.

A misunderstanding of object lifetime, async behavior, query execution, mutation, equality, or resource ownership can create a bug that takes hours to find.

Sometimes days.

Production software has repeatedly hammered that lesson into me.

The code that causes the worst problems isn't always complicated.

Sometimes it's completely ordinary code built on one incorrect assumption.

You Don't Outgrow the Fundamentals

Ten years ago, revisiting C# fundamentals would have felt like going backward.

Today I see it differently.

I don't need to prove that I remember every detail of the language without checking documentation.

I need to make good engineering decisions.

Sometimes that means opening the documentation.

Sometimes it means writing a tiny console application to verify behavior.

Sometimes it means reconsidering my position on a pattern I've used for years.

And sometimes it means returning to something I supposedly "learned" a decade ago and realizing I understand it differently now.

That's not going backward.

That's what experience does.

You stop collecting syntax.

You start understanding consequences.

And in a language as mature and capable as C#, there is always another layer underneath something you thought you already knew.


I'm Ayman Atif, a software engineer with 10+ years of experience building and maintaining production software with C#, .NET, Python, SQL, APIs, automation, financial systems, CRM platforms, and business applications.

I write about lessons from building real software, including the mistakes, architecture decisions, performance problems, and fundamentals that become more important as applications grow.

Portfolio and projects:
Ayman Atif — Software Engineer

Top comments (0)