Telerik blogs
ASP.NET Core

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.

1. Pattern Matching

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.

1.1. Without 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.";
    }
}

1.2. Using Pattern Matching

// 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."
 };

1.3. Using Pattern Matching: 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.

2. Static Methods

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.

2.1. Non-static Method

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}");

2.1. Static Method

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.

3. Tuples

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.

4. Expression-Bodied Members

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:

4.1. Block Approach

 public int TraditionalSum(int x, int y)
  {
      return x + y;
  }

4.2. Expression-Bodied Members Approach

   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.

4.3. Properties Using Expression-Bodied Members Approach

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.

5. Scoped Namespaces

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:

5.1. Traditional Namespace

namespace AmazingFeatures.Models
{
    public class Address
    {
        public string AddressName { get; set; }
    }
}

5.2. File-scoped Namespace

namespace AmazingFeatures.Models;

public class Address
{
    public string AddressName { get; set; }
}

6. Records

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.

6.1. Model Class

   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);

6.2. Model Record

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.

7. Delegate Func

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.

7.1. Manual Delegate

// 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.

7.2. Delegate Func

// 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.

8. Global Using

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:

8.1. Traditional Using Directive

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:

8.2. Global Using Directive

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.

9. Data Annotations

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.

9.1. Using a Validation Method

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.

9.2. Using Data Annotations

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.

10. Generics

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.

10.1. Without 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 });

10.2. Using Generics

    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.

Conclusion

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.


assis-zang-bio
About the Author

Assis Zang

Assis Zang is a software developer from Brazil, developing in the .NET platform since 2017. In his free time, he enjoys playing video games and reading good books. You can follow him at: LinkedIn and Github.

Comments

Comments are disabled in preview mode.