Stuck in a development rut? These 10 ASP.NET Core features might inspire you to try a new approach!
As web developers, it is common for us to keep turning to the same old solutions for different problems, especially when we are under pressure or dealing with tight deadlines.
This happens because we often follow code patterns that we have already mastered, either because we are unaware of other alternatives or even because we are afraid of using new options and things getting out of control. However, ASP.NET Core offers features that can make code cleaner and more efficient, and they are worth learning about.
In this post, we will explore some of these features. We will cover how features such as pattern matching, local functions and extension methods, among others, can be applied more effectively in different scenarios. You can access all the code examples covered during the post in this GitHub repository: ASP.NET Core Amazing features.
Pattern matching first appeared in C# 7 as a way to check the structure and values of objects more concisely and expressively. It has been continually improved since then.
In pattern matching, you test an expression to determine whether it has certain characteristics. The is
and switch
expressions are used to implement pattern matching.
public string GetTransactionDetails(object transaction)
{
if (transaction == null)
{
return "Invalid transaction.";
}
if (transaction is Payment)
{
Payment payment = (Payment)transaction;
return $"Processing payment of {payment.Amount:C} to {payment.Payee}";
}
else if (transaction is Transfer)
{
Transfer transfer = (Transfer)transaction;
return $"Transferring {transfer.Amount:C} from {transfer.FromAccount} to {transfer.ToAccount}";
}
else if (transaction is Refund)
{
Refund refund = (Refund)transaction;
return $"Processing refund of {refund.Amount:C} to {refund.Customer}";
}
else
{
return "Unknown transaction type.";
}
}
// Using pattern matching - Switch expression
public string GetTransactionDetailsUsingPatternMatching(object transaction) => transaction switch
{
null => "Invalid transaction.",
Payment { Amount: var amount, Payee: var payee } =>
$"Processing payment of {amount:C} to {payee}",
Transfer { Amount: var amount, FromAccount: var fromAccount, ToAccount: var toAccount } =>
$"Transferring {amount:C} from {fromAccount} to {toAccount}",
Refund { Amount: var amount, Customer: var customer } =>
$"Processing refund of {amount:C} to {customer}",
_ => "Unknown transaction type."
};
is
Explicit Expression public void ValidateObject()
{
object obj = "Hello, world!";
if (obj is string s)
{
Console.WriteLine($"The string is: {s}");
}
}
Note that in example 1.1, without pattern matching, despite using the is
operator to check the transaction type, there is a chain of if
and else
statements. This makes the code very long. Imagine if there were more transaction options—the method could become huge.
In example 1.2, we use the switch operator to check the transaction type, which makes the code much simpler and cleaner.
In example 1.3, we use the is
expression explicitly to check whether obj is
of type string. In addition, is string s
performs a type-check and initializes the variable s
as the value of obj
converted to a string, if the check is true. This way, in addition to checking the type, we can convert this value to the checked type.
Static methods are methods associated with the class to which they belong, and not with specific instances of the class. In other words, unlike non-static methods, you can call them directly using the class name, without having to create a new instance of it.
The best-known extension methods are the LINQ query operators that add query functionality. But in addition to LINQ query methods, we can create our own static methods that, in addition to keeping the code cleaner and simpler, can be shared with other system modules. Furthermore, they are more efficient than non-static methods, since they do not require instance management.
See below the same method declared and called statically and non-statically.
public class BankAccount
{
public double Balance { get; set; }
public double InterestRate { get; set; }
//Non-static method
public BankAccount(double balance, double interestRate)
{
Balance = balance;
InterestRate = interestRate;
}
public double CalculateInterest()
{
return Balance * (InterestRate / 100);
}
}
// Using non-static-method
BankAccount account = new BankAccount(1000, 5);
double interest = account.CalculateInterest();
Console.WriteLine($"Interest earned: ${interest}");
public static class BankAccountUtility
{
//Static method
public static double CalculateInterest(double balance, double interestRate)
{
return balance * (interestRate / 100);
}
}
// Using static method
double interestStatic = BankAccountUtility.CalculateInterest(1000, 5);
Console.WriteLine($"Interest earned: ${interestStatic}");
Note that in example 2.1 we created the class in a non-static way and with properties, and we also created the non-static method. Therefore, when we called the method, it was necessary to create an instance of the class to invoke the method.
In example 2.2, we created the class and method in a non-static way, which allowed the method to be called without the need to instantiate the class. We simplified things even further by not creating properties for the class.
In this way, the static approach removes the coupling between the interest calculation and the BankAccount
object. This is useful because the interest calculation in this scenario is a generic operation that does not need to be tied to an object and can be reused in different contexts, without depending on the structure or state of a class.
Another advantage of the static approach is that potential bugs are minimized, as there is no manipulation of instances of the BankAccount
class—that is, there is no change in state for the objects.
Tuples are a data structure that allows you to store values of different types, such as string
and int
, at the same time without the need to create a specific class for this.
Note the example below:
class NameParts
{
public string FirstName { get; }
public string LastName { get; }
public NameParts(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}
}
static NameParts ExtractNameParts(string fullName)
{
var parts = fullName.Split(' ');
string firstName = parts[0];
string lastName = parts.Length > 1 ? parts[1] : string.Empty;
return new NameParts(firstName, lastName);
}
string fullName = "John Doe";
var nameParts = ExtractNameParts(fullName);
Console.WriteLine($"First Name: {nameParts.FirstName}, Last Name: {nameParts.LastName}");
Here, we declare the NameParts
class that has the FirstName
and LastName
properties to store the value obtained in the ExtractNameParts(string fullName)
method. There is also a method to display the values found.
In this case, we use a class and properties only to transport the data. But we could simplify this by using a tuple. Now see the same example using a tuple:
//Now the method returns a tuple
static (string FirstName, string LastName) ExtractNamePartsTuple(string fullName)
{
var parts = fullName.Split(' ');
string firstName = parts[0];
string lastName = parts.Length > 1 ? parts[1] : string.Empty;
return (firstName, lastName);
}
public void PrintNameTuple()
{
string fullName = "John Doe";
var nameParts = ExtractNamePartsTuple(fullName);
Console.WriteLine($"First Name: {nameParts.FirstName}, Last Name: {nameParts.LastName}");
}
The above example does not use a class to store the value of FirstName
and LastName
. Instead, a tuple is used to return the values of ExtractNamePartsTuple(string fullName)
by just declaring the code (string FirstName, string LastName)
.
Using tuples allows the developer to keep the code straightforward, as it avoids the creation of extra classes to transport data. However, in scenarios with a certain level of complexity, it is recommended to use classes to establish the maintainability and comprehensibility of the code. Furthermore, when using tuples, it is important to give meaningful names to the values stored in them. This way the developer makes the meaning of each element of the tuple explicit.
Expression body definitions are a way to implement members (methods, properties, indexers or operators) through expressions instead of code blocks using {}
in scenarios where the member body is simple and contains only one expression.
In Example 4.1 you can see a method using the traditional block approach, while 4.2 shows the same method using the expression-bodied members approach:
public int TraditionalSum(int x, int y)
{
return x + y;
}
public int Sum(int x, int y) => x + y;
Note that the method that uses expression-bodied members is simpler than the traditional approach, as it does not require the creation of blocks or the return
expression, which leaves the method with just a single line of code.
Note in the example below that it is also possible to use the expression-bodied members approach in class properties, where the get
property and the set
accessors are implemented.
public class User
{
// Expression-bodied properties
private string userName;
private User(string name) => Name = name;
public string Name
{
get => userName;
set => userName = value;
}
}
Just like pattern matching, using expression-bodied members allows you to write simpler code, saving lines of code, and fits well in scenarios that don’t require complexity.
Scoped namespaces are useful for files that have only a single namespace, typically model classes.
Note the examples below. Example 5.1 shows the traditional namespace declaration format using curly braces, while Example 5.2 shows the same example but using the file-scoped namespace format, notice the semicolon and the absence of curly braces:
namespace AmazingFeatures.Models
{
public class Address
{
public string AddressName { get; set; }
}
}
namespace AmazingFeatures.Models;
public class Address
{
public string AddressName { get; set; }
}
Records are classes or structs that provide distinct syntax and behaviors for creating data models.
Records are useful as a replacement for classes or structs when you need to define a data model that relies on value equality—that is, when two variables of a record type are equal only if their types match and all property and field values are equal.
In addition, records can be used to define a type for which objects are immutable. An immutable type prevents you from changing any property or field value of an object after it has been instantiated.
The examples below show a common class used to create a model, followed by the same example in record format.
public class Product
{
public string Name { get; set; }
public decimal Price { get; set; }
public string Category { get; set; }
public Product(string name, decimal price, string category)
{
Name = name;
Price = price;
Category = category;
}
}
// Using mutable class
var product1 = new Product("Laptop", 1500.00m, "");
var product2 = new Product("", 1500.00m, "Electronics");
product1.Category = "Electronics";
product2.Name = "Laptop";
// Class object comparison (by reference)
Console.WriteLine(product1 == product2); // False (comparison by reference);
Console.WriteLine(product1.Equals(product2)); // False (no value equality logic);
public record RecordProduct(string Name, decimal Price, string Category);
// Using immutable record
var recordProduct1 = new RecordProduct("Laptop", 1500.00m, "Electronics");
var recordProduct2 = new RecordProduct("Laptop", 1500.00m, "Electronics");
// Record comparison (by value, native)
Console.WriteLine(recordProduct1 == recordProduct2); // True (comparison by value);
Console.WriteLine(recordProduct1.Equals(recordProduct2)); // True (comparison by value);
In example 6.1, the properties of the Product class (Name, Price and Category) are freely assigned and changed, since the class is mutable by default.
Note that when the ==
operator is used to compare two instances of Product
, the result is False
, even though all the property values are identical. This is because, in classes, the ==
operator compares only the memory references of the objects, and not the values of their properties, in the same way as the Equals
method.
Example 6.2 uses a record to represent the product. Unlike the class, the record is immutable by default—that is, the property values are defined at the time of creation and cannot be changed later. This immutability makes records an ideal choice for representing data that does not need to be modified, so they are consistent and predictable.
Another point to note is the comparison of objects. Records have comparison by value natively. This means that the ==
operator and the Equals
method compare the values of all the object’s properties rather than their references in memory. In Example 6.2, recordProduct1
and recordProduct2
have the same values for all their properties, which causes both comparisons to return True
. This value-based comparison is useful for scenarios where the object’s content is more important than its reference, such as reading and writing data.
Delegate is a type that represents a reference to a method, like a pointer. Func is a native .NET generic delegate that can be used to represent methods that return a value and can receive input parameters.
The example below demonstrates creating a delegate manually.
// Explicit definition of the delegate
public delegate int SumDelegate(int a, int b);
// Using delegate
static void UsingDelegate()
{
// Method reference
SumDelegate sum = SumMethod;
Console.WriteLine(sum(3, 4)); // Displays 7
}
// Method associated with the delegate
static int SumMethod(int a, int b)
{
return a + b;
}
Note that in example 7.1 a delegate is declared to represent a method that performs the sum of two integers.
SumDelegate
is declared explicitly. It represents any method that accepts two integer parameters (int a
and int b
) and returns an integer value. The delegate functions as a contract, specifying the signature of methods that can be assigned to it. The SumMethod
method fulfills the requirements of the signature defined by the SumDelegate
delegate: two integer parameters as input and one integer as output, and performs the sum of the two numbers provided.
In the UsingDelegate
function, an instance of the SumDelegate
delegate is created and associated with the SumMethod
method. Thus, when calling the delegate (sum(3, 4)
), it redirects the call to SumMethod
with the parameters provided, performing the sum and returning the result. Although it works, this approach requires the explicit definition of the delegate (public delegate int SumDelegate(int a, int b)
), which makes the code longer and less efficient, especially for simple tasks like adding two numbers.
An alternative to this could be to create a delegate func. See the example below.
// Using func delegate
Func<int, int, int> sum = (a, b) => a + b;
public void UsingFuncDelegate()
{
Console.WriteLine(sum(3, 4)); // Displays 7
}
Note that we are now using the generic delegate Func
to represent anonymous methods with input parameters and return values—that is, the return of the expression (a, b) => a + b;
.
Func
is a generic type that can represent methods with up to 16 parameters and a return type.
In the example above, the first two generic types passed to Func
are two integers, a
and b
. The third int represents the return type of the method. The anonymous method is defined using a lambda expression (a, b) => a + b
, which indicates that the method receives two integers as input and returns the sum of these two integers.
Consider using delegate func to create cleaner and more flexible code. Furthermore, delegate func allows the creation of generic and dynamic functions that can be passed as parameters, stored in variables and combined to perform complex operations, but in a way that makes the code easier to understand and read.
Global using is a feature that has been available since C# 10 and its principle is to reduce boilerplate and make code simpler, declaring the using directive only once and sharing it throughout the application.
Note the approach below using the traditional directive form:
using AmazingFeatures.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
namespace AmazingFeatures.Data;
public class AmazingHelper
{
private readonly AmazingContext _amazingContext;
public AmazingHelper(AmazingContext amazingContext)
{
_amazingContext = amazingContext;
}
public static void MigrationInitialisation(IApplicationBuilder app)
{
using (var serviceScope = app.ApplicationServices.CreateScope())
{
var context = serviceScope.ServiceProvider.GetRequiredService<AmazingContext>();
if (!context.Database.GetService<IRelationalDatabaseCreator>().Exists())
{
context.Database.Migrate();
}
}
}
public List<Product> GetProductsWithPrice() =>
_amazingContext.Products.Where(p => p.Price > 0).ToList();
}
Note that in the class above we declared four usings, three of which are from EntityFrameworkCore
and one from the Product
model class.
Imagine that this class grows exponentially, using other classes from other namespaces. This would leave the beginning of the class with dozens of usings. Thus, with the global using feature, we can eliminate all using directives from this class.
To do this, simply create a class with a name like GlobalUsings.cs
or Globals.cs
and place the directives you want to share in it:
global using AmazingFeatures.Models;
global using Microsoft.EntityFrameworkCore;
global using Microsoft.EntityFrameworkCore.Infrastructure;
global using Microsoft.EntityFrameworkCore.Storage;
Note that, in this case, it is not necessary to declare a name for this class, nor even a namespace for it. It must only contain the global using directives, with the reserved word global
before each directive.
Now, we can declare the AmazingHelper
class without any using directive, because the compiler understands that the necessary usings have already been declared globally and are shared by the application.
namespace AmazingFeatures.Data;
public class AmazingHelper
{
private readonly AmazingContext _amazingContext;
public AmazingHelper(AmazingContext amazingContext)
{
_amazingContext = amazingContext;
}
public static void MigrationInitialisation(IApplicationBuilder app)
{
using (var serviceScope = app.ApplicationServices.CreateScope())
{
var context = serviceScope.ServiceProvider.GetRequiredService<AmazingContext>();
if (!context.Database.GetService<IRelationalDatabaseCreator>().Exists())
{
context.Database.Migrate();
}
}
}
public List<Product> GetProductsWithPrice() =>
_amazingContext.Products.Where(p => p.Price > 0).ToList();
}
Implementing global usings is helpful in scenarios where there are classes with many usings directives, such as service classes, where resources from different sources are regularly incorporated. With global using directives, it is possible to eliminate many lines of code from different files since the entire application can share global resources.
The Data Annotations resource consists of a set of attributes from the System.ComponentModel.DataAnnotations
namespace. They are used to apply validations, define behaviors and specify data types on model classes and properties.
Let’s check below an example where it is possible to eliminate a validation method with just two data annotations.
public class Product
{
public string Name { get; set; }
public List<string> Validate()
{
var errors = new List<string>();
if (string.IsNullOrEmpty(Name))
{
errors.Add("The name is required.");
}
else if (Name.Length > 100)
{
errors.Add("The name must be at most 100 characters long.");
}
return errors;
}
}
// Using the validation method
var product = new Product { Name = "", Category = "keyboard", Price = 100 };
var errors = product.Validate();
if (errors.Any())
{
foreach (var error in errors)
{
Console.WriteLine(error);
}
}
Note that in the approach above it is necessary to create a method in the Product
class to validate whether the Name
property is null or empty and whether it has more than 100 characters.
using System.ComponentModel.DataAnnotations;
public class ProductDataAnnotation
{
[Required(ErrorMessage = "The name is required.")]
[StringLength(100, ErrorMessage = "The name must be at most 100 characters long.")]
public string Name { get; set; }
}
// Using data anotations
var productDataAnnotation = new ProductDataAnnotation { Name = "" };
var validationResults = new List<ValidationResult>();
var validationContext = new ValidationContext(product);
if (!Validator.TryValidateObject(product, validationContext, validationResults, true))
{
foreach (var validationResult in validationResults)
{
Console.WriteLine(validationResult.ErrorMessage);
}
}
In the above approach with data annotations instead of a validation method, we just add the data annotations above the Name
property. Then in the service class, we use the method for validation. This approach reduces the use of manual validations with expressions like if
and else
for each of the properties present in the model class. You only need to add the data annotations and validate them once.
Generics allow you to define classes, interfaces, methods and structures that can use generic data types. This means that the data type to be used is specified only at the time the class, method or interface is instantiated or used. In this way, they make your code more flexible, reusable and type-safe compared to generic types such as objects, which require manual casting.
Below are two examples. The first one is without the use of generics and the second one uses generics.
public int FindMaxInt(List<int> numbers)
{
return numbers.Max();
}
public double FindMaxDouble(List<double> numbers)
{
return numbers.Max();
}
var maxInt = FindMaxInt(new List<int> { 1, 2, 3 });
var maxDouble = FindMaxDouble(new List<double> { 1.1, 2.2, 3.3 });
public T FindMax<T>(List<T> items) where T : IComparable<T>
{
return items.Max();
}
public void UsingGenerics()
{
var maxInt = FindMax(new List<int> { 1, 2, 3 });
var maxDouble = FindMax(new List<double> { 1.1, 2.2, 3.3 });
}
Note that in the first approach without generics, the code implements two methods: one to get the maximum value from a list of integers, and another to get the maximum value from a list of double types.
In the second example, a single method is implemented that expects a list of T
—that is, a generic type instead of a specific type such as int
or double
as in the previous example.
Despite the safety traditional approaches bring when implementing new code, it is important for web developers to consider alternatives that may be more efficient, depending on the scenario.
In this post, we covered 10 ASP.NET Core features that fit well in different situations. So, whenever the opportunity arises, consider using some of these features to further improve your code.