C# Essentials for .NET Developers: Types, LINQ, and Async/Await

7 minute read

Published:

This post covers the C# features every .NET developer uses daily. You do not need to master every corner of the language on day one, but you do need a solid grip on how types behave, how LINQ transforms data, and how async/await keeps your application responsive. These three areas show up in almost every code review, bug report, and production service.

Why C# fundamentals matter

The .NET ecosystem is wide, but the language patterns repeat everywhere:

  • APIs receive DTOs and map them to domain types
  • services query collections and databases using LINQ-like syntax
  • apps call networks, disks, and queues asynchronously

When these foundations are weak, common problems follow:

  • null reference exceptions
  • confusing collection code
  • slow or blocked threads
  • async deadlocks or fire-and-forget bugs

The goal is not just “write code that works”. The goal is write code that is predictable under load and easy for the next developer to read.

Understanding core C# types

C# has two broad categories of types:

  • value types
  • reference types

Value types store their data directly. Common examples are:

  • int
  • decimal
  • bool
  • DateTime
  • struct

Reference types store a reference to an object. Common examples are:

  • string
  • arrays
  • class
  • record class
  • interfaces

Example:

int quantity = 5;
decimal price = 19.99m;
bool isInStock = true;
DateTime createdOn = DateTime.UtcNow;

string productName = "Mechanical Keyboard";
int[] scores = [10, 20, 30];

Why the difference matters:

  • value types are copied by value unless wrapped or boxed
  • reference types point to shared objects
  • nullability rules are different between them

For example:

int a = 10;
int b = a;
b = 20;

Console.WriteLine(a); // 10
Console.WriteLine(b); // 20

But with a reference type:

var order = new Order { Id = 1 };
var sameOrder = order;

sameOrder.Id = 99;

Console.WriteLine(order.Id); // 99

Both variables refer to the same object instance.

Nullable types and null safety

In modern .NET projects, nullable reference types should stay enabled:

<Nullable>enable</Nullable>

That tells the compiler to warn you when code may dereference null.

Examples:

int? optionalCount = null;
string? middleName = null;
string firstName = "Santosh";

Use nullable types when “no value” is a valid state. Do not use them everywhere by default. A property that must exist should remain non-nullable and be initialized correctly.

Bad habit:

public string Name { get; set; }

Better:

public string Name { get; set; } = string.Empty;

Or require it through the constructor:

public sealed record Customer(string Name, string Email);

var, explicit types, and readability

var does not mean “dynamic”. It means the compiler infers the type.

var total = 42.50m;          // decimal
var customers = new List<string>();

Use var when the type is obvious from the right side. Avoid it when the type is unclear and would force the reader to guess.

Good:

var stream = new FileStream(path, FileMode.Open);

Less clear:

var result = service.Execute();

In the second case, an explicit type may communicate intent better.

Collections you should recognize immediately

You will constantly see these collection types:

  • T[] for arrays
  • List<T> for mutable ordered lists
  • Dictionary<TKey, TValue> for key-value lookup
  • IEnumerable<T> for sequences you can iterate
  • IReadOnlyList<T> when callers should not mutate the collection

Example:

var products = new List<Product>
{
    new(1, "Keyboard", 129.00m, true),
    new(2, "Mouse", 59.00m, true),
    new(3, "Cable", 12.00m, false)
};

If a method only needs to read data, returning IReadOnlyList<T> or IEnumerable<T> often communicates intent better than returning a mutable List<T>.

LINQ basics

LINQ stands for Language Integrated Query. It gives you a standard way to filter, project, sort, and aggregate data.

Common operators:

  • Where filters
  • Select projects
  • OrderBy sorts
  • Any checks for existence
  • FirstOrDefault returns one element or a default value
  • GroupBy groups elements
  • Sum, Count, Max, and Min aggregate values

Given this model:

public sealed record Product(int Id, string Name, decimal Price, bool IsActive);

You can write:

var activeProductNames = products
    .Where(p => p.IsActive)
    .OrderBy(p => p.Name)
    .Select(p => p.Name)
    .ToList();

This reads as:

  1. keep only active products
  2. sort by name
  3. select only the name
  4. materialize the results as a list

LINQ query syntax also exists:

var activeProductNames =
    (from p in products
     where p.IsActive
     orderby p.Name
     select p.Name).ToList();

Most .NET teams prefer method syntax because it is more common in modern codebases and chains well with extension methods.

Deferred execution vs materialization

This is one of the most important LINQ concepts.

Many LINQ operations are lazily evaluated. That means the query is not executed until you iterate it.

var activeProducts = products.Where(p => p.IsActive);

At this point, no list has been created yet. Execution happens when you loop over activeProducts or call a materializing method such as:

  • ToList()
  • ToArray()
  • ToDictionary()

Why it matters:

  • deferred execution can be efficient
  • it can also lead to repeated work if you enumerate the sequence multiple times
  • with database providers like Entity Framework Core, the query may not execute until materialization

If you need a stable snapshot, materialize it once:

var activeProducts = products
    .Where(p => p.IsActive)
    .ToList();

Async and await

Asynchronous programming is essential whenever your app waits on external work such as:

  • HTTP calls
  • database access
  • file I/O
  • message brokers

The key rule is simple: do not block a thread while waiting for I/O if an asynchronous API exists.

Example service:

public sealed class ProductApiClient(HttpClient httpClient)
{
    public async Task<string> GetProductsJsonAsync(CancellationToken cancellationToken)
    {
        using var response = await httpClient.GetAsync(
            "/api/products",
            cancellationToken);

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync(cancellationToken);
    }
}

What is happening here:

  • the method returns Task<string>
  • await pauses the method without blocking the thread
  • execution resumes when the HTTP operation completes

An async call site looks like:

var json = await client.GetProductsJsonAsync(cancellationToken);
Console.WriteLine(json);

Guidelines for async methods

Keep these rules in mind:

  • return Task or Task<T> from async methods
  • use async void only for UI event handlers
  • propagate CancellationToken when available
  • prefer await over .Result or .Wait()
  • name async methods with the Async suffix

Bad:

var json = client.GetProductsJsonAsync(CancellationToken.None).Result;

Blocking like this can reduce throughput and, in some application models, cause deadlocks.

Running multiple async operations

If independent operations can run at the same time, use Task.WhenAll:

var inventoryTask = inventoryClient.GetInventoryAsync(cancellationToken);
var pricingTask = pricingClient.GetPricingAsync(cancellationToken);

await Task.WhenAll(inventoryTask, pricingTask);

var inventory = await inventoryTask;
var pricing = await pricingTask;

This is better than awaiting each one sequentially when there is no dependency between them.

A realistic example that combines types, LINQ, and async

The following method fetches products, filters them, and projects a response model:

public sealed record ProductDto(int Id, string Name, decimal Price);

public sealed class ProductService(ProductApiClient client)
{
    public async Task<IReadOnlyList<ProductDto>> GetActiveProductsAsync(
        CancellationToken cancellationToken)
    {
        var json = await client.GetProductsJsonAsync(cancellationToken);

        var products = JsonSerializer.Deserialize<List<Product>>(
            json,
            new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? [];

        return products
            .Where(p => p.IsActive)
            .OrderBy(p => p.Name)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price))
            .ToList();
    }
}

That method demonstrates several everyday patterns:

  • non-null fallback with ?? []
  • LINQ projection to a DTO
  • Task<IReadOnlyList<ProductDto>> for asynchronous results
  • explicit cancellation support

Common mistakes to avoid

Watch for these problems in beginner C# code:

  • disabling nullable warnings instead of fixing them
  • returning mutable collections when callers should not change data
  • chaining too much LINQ into unreadable one-liners
  • mixing synchronous and asynchronous APIs in the same workflow
  • swallowing exceptions inside async methods without logging or handling them meaningfully

The best C# code is not the cleverest code. It is the code a teammate can read quickly and trust.


Next Article: Configuration and Secrets in .NET 8: appsettings.json, Environment Variables, and User Secrets