Aspect-oriented programming (AOP) is a programming paradigm that attempts to increase the modularity, maintainability, and flexibility of your application while overcoming the traditional object-oriented programming (OOP) limitations. AOP adds a new programming concept, known as an aspect, which are reusable, modular pieces of code that can solve common problems like logging, performance management, exception handling, configuration management, and security. Aspects can be used to combine similar concerns into a single unit and applied to an application's core logic via weaving.

This article compares AOP to OOP and procedural programing, and provides a deep dive into AOP, explaining why it's useful, and illustrating how you can optimize performance of an ASP.NET Core application using AOP and then store the captured performance metadata in a database.

If you're to work with the code examples discussed in this article, you need the following installed in your system:

  • Visual Studio 2022
  • .NET 8.0
  • ASP.NET 8.0 Runtime

If you don't already have Visual Studio 2022 installed on your computer, you can download it from here: https://visualstudio.microsoft.com/downloads/.

In this article, I'll examine the following points:

  • Procedural, OOP, and AOP programming styles and their benefits
  • AOP and its features, benefits, and downsides
  • The core concepts of AOP
  • How to use Autofac to create a memory cache interceptor
  • How to use PostSharp to build an application that stores application performance metadata in a database
  • How to use PostSharp to store CPU and memory usage details in a database

What Is a Programming Paradigm?

The term “paradigm” is analogous to an approach or style used in programming to solve a specific problem. A number of programming paradigms have evolved over the years, including imperative, structured, procedural, functional, declarative, event-driven, object-oriented, and aspect-oriented. Programming has evolved into a variety of paradigms since its infancy, and we've witnessed the emergence of several programming paradigms since the early days of programming. These paradigms have fostered the growth of several new programming languages. Procedural programming, object-oriented programming, and aspect-oriented programming are typical examples of such paradigms.

Procedural programming is a programming paradigm that was popular worldwide before the advent of object-oriented programming. Often contrasted with object-oriented programming, procedural programming is one of the foundational programming paradigms that thrives on writing procedures or routines to execute tasks in a program. Typical examples of procedural programming languages are C, Pascal, Fortran, etc. The major downsides of procedural programming are code tangling and that it didn't support reusability. Object-oriented programming was invented primarily to address the pitfalls of procedural programming.

What Is Object-Oriented Programming (OOP)?

Object-oriented programming (OOP) helps organize an application in a more structured and modular way by representing real-world entities as objects, each with its own set of attributes (or data) and behaviors (also known as methods). Although OOP is a programming paradigm that focuses on encapsulation of data and behavior into objects, AOP extends OOP by focusing on the separation of concerns in an application.

Features of OOP

The key concepts of OOP include the following:

  • Classes: A class is defined as a blueprint based on which instances of classes or objects are created. Classes are known as the building block of OOP that encompasses both data and the functions (in OOP, functions are also known as methods) that operate on the data.
  • Objects: Objects are defined as instances of classes that are created based on the template or structure as defined by a class. They can interact with one another through method calls.
  • Encapsulation: Encapsulation (also known as information hiding) is a fundamental concept in OOP that involves combining data (attributes, properties) and methods (functions, operations) that operate on data inside a single unit, known as a class.
  • Inheritance: Inheritance is a feature of OOP that enables a subclass or derived class to extend or inherit the attributes, properties, and methods of its base class.
  • Polymorphism: Polymorphism is a feature of OOP that enables objects of multiple child classes to be treated as pertaining to a common base class, and thereby enabling the same method to behave differently depending on the object that calls the method.

Benefits of OOP

The key benefits of OOP include the following:

  • Modularity: One of the key benefits of OOP is modularity, which makes it easier to comprehend, manage, and maintain the source code.
  • Reusability: In OOP, you can create new classes by extending existing classes, thereby promoting reusability.
  • Maintainability: You can take advantage of OOP to alter components without affecting other parts of the application, thereby making it easier to maintain the source code over a period of time.
  • Flexibility: You can leverage polymorphism, a feature of OOP, to enable methods to perform different actions depending on the object they are acting upon.

Code Tangling and Code Scattering

Although there are benefits, there are certain downsides to using OOP as well. OOP can introduce complexity because the additional layers of abstraction can lead to degraded performance. However, the two major downsides of OOP are code tangling and code scattering.

Code tangling and code scattering are two of the major downsides of OOP. OOP requires that the application's source code be organized around classes and objects. However, you often have codebases that contain tightly coupled code, i.e., code comprised of components that are interdependent. If your application's source code is tightly coupled or interdependent, it makes isolating specific functionality difficult, which leads to tangled code.

Code tangling is an example of spaghetti code that often occurs when an application's unrelated concerns are intertwined in a particular class or module. Spaghetti code, an outcome of bad programming practices, is a term that describes code that's difficult to understand or maintain, because it's tangled, complex, and difficult to comprehend. When the application's source code is tangled, it can be challenging to comprehend and manage complex code bases, as changes to the code in one part of the application can have unexpected consequences in another.

Another downside of OOP is code scattering, which occurs when an application's related functionality is spread across multiple classes, making the codebase excruciatingly difficult to understand and maintain. The codebase of an application will become easier to manage, understand, and maintain if its related functionality is organized coherently. The reuse of code between applications can become difficult if functionality is spread across multiple classes and tied to specific implementations.

To reduce or eliminate code scattering, you can adopt object-oriented design principles, such as the single responsibility principle and the open/closed principle. You'll learn how AOP can help overcome these challenges in the sections that follow.

What Are Cross-Cutting Concerns?

Typically, an application has two sorts of concerns: core and cross-cutting. Core concerns in an application refer to the business logic and primary use cases of an application. Typical examples of core concerns include business logic, data access, etc.

A cross-cutting concern is a specific aspect of the application that encompasses a collection of logic and functionality with specified requirements and responsibilities. Cross-cutting concerns in an application represent functionality that cuts across multiple components and is orthogonal to core concerns. For example, an application's business logic and data access layers often require you to log data. An application's cross-cutting concerns include logging, security, performance, error handling, caching, and transaction management. You can see an example of cross-cutting concerns in Figure 1.

Figure 1: Cross-cutting concerns in an application
Figure 1: Cross-cutting concerns in an application

In an application, cross-cutting concerns help implement separation of concerns and is a design principle that promotes modularity, reusability, easier maintenance, and reduces inconsistencies in your application's codebase.

What Is Aspect-Oriented Programming (AOP)?

In traditional object-oriented programming (OOP), it often becomes extremely difficult to manage the application's cross-cutting concerns because they're entangled with the application's core concerns. Aspect-oriented programming (AOP) is a programming approach that decouples the cross-cutting concerns of an application from that application's core, resulting in enhanced code reuse and modularity.

Object-oriented programming (OOP) and aspect-oriented programming (AOP) are two widely used programming paradigms that have distinct characteristics. Although object-oriented programming (OOP) is centered on the concept of objects, aspect-oriented programming (AOP) mainly emphasizes aspects. An aspect is a modularization of a concern that cuts across several places in your application's codebase. A typical example of an AOP aspect is a logging module. AOP doesn't replace OOP; rather, it complements OOP by adding reusable implementations.

To implement AOP in your applications, you can take advantage of code weaving, dynamic proxies, or bytecode manipulation approaches. Each of these approaches allows you to attach aspects without affecting the original program code. Some of the widely used and most popular AOP frameworks include PostSharp, Castle Dynamic Proxy, Spring, and AutoFac. In this article, I'll discuss Autofac and PostSharp.

How Does AOP Work?

In this section, I'll examine how AOP works and the related concepts. Figure 2 illustrates all the bits of AOP.

Figure 2: The core concepts of AOP
Figure 2: The core concepts of AOP

Note that weaving and method intersection are the two ways in which AOP links aspects to an application's code. Weaving is a strategy adopted to untangle code by applying an aspect to an application's source code at the relevant join points. In AOP, method interception refers to the ability to intercept method calls, thereby enabling additional behaviors to be injected either before, after, or around the original method invocation.

You can isolate the cross-cutting concerns of an application from its code by intercepting method calls and injecting code at different points when the methods are in execution at runtime. Method interception refers to the practice of altering the runtime behavior of methods. This is achieved by splitting the methods into smaller chunks of functionality known as aspects. These aspects are in turn integrated into the source code of a target method via proxies or decorators. An interceptor enables additional functionality to be added to a method call by intercepting method invocations and forwarding them to the target object.

Aspects are a set of join points and pointcuts added to an application's source code. As the name implies, join points are specific points in the program's execution flow when an aspect may be applied, such as method calls, object creation, field accesses, and exception handling routines. For example, a join point may indicate that an aspect be applied to all method calls with a given name or class.

Pointcuts are one or more join points that indicate where an aspect should be applied based on the names of methods, parameter types, or annotations. An advice refers to the code used to implement an aspect's behavior at a certain join point. Advice takes many forms, including before, after, and around. As the names imply, a “before advice” runs before to the execution of the join point, an “after advice” runs after the execution, and an “around advice” runs both before and after.

The following code snippet illustrates how you can implement a custom method interception aspect.

[Serializable]

public class 
MyCustomInterceptorAspect : 
MethodInterceptionAspect 
{
    public override void 
    OnInvoke(MethodInterceptionArgs args) 
    { 
  
    }
}

You can implement weaving using several approaches, such as bytecode modification and dynamic proxies. Bytecode modification is a technique used to modify the bytecode dynamically at runtime without altering the original source code. In AOP, a dynamic proxy is used to create a proxy instance for a target object, thereby enabling you to attach additional behaviors such as logging, transaction management, exception handling, etc. There are several ways to implement weaving in AOP, including compile-time weaving, load-time weaving, and runtime weaving. By incorporating aspects into a program, the AOP framework intercepts execution at the join locations indicated by the pointcut and applies the advice described in the aspect, as shown in Figure 3.

Figure 3: Weaving in action
Figure 3: Weaving in action

Implementing Cross-Cutting Concerns Using AOP

Let's now understand how usage of AOP can help you write cleaner, more manageable, and reusable code. Refer to the following piece of code:

public class CacheAspect: OnMethodBoundaryAspect
{
  //Write your code here to cache data
}

public class LogAspect: OnMethodBoundaryAspect
{
   //Write your code here to log data
  // to a pre-defined log target
}

public class OrderRepository
{
    [CacheAspect]
    public Task<IEnumerable<Order>> 
    GetAllAsync()
    {
      //Code to return all orders 
      //from the database
    }

    [LogAspect]
    public Task SaveAsync(Order order)
    {
      //Code to save an order 
      //object to the database
    }
}

In the preceding code snippet, the aspects Logging and Caching have been applied on the GetAllAsync and SaveAsync method respectively. In this example, I've leveraged AOP into the OrderRepository class. You can apply aspects at the method level, property level, and the class level as well. Although aspects applied to a particular method are applicable for the particular method only, those applied at the class level are applicable for all members (i.e., methods and properties) of the class.

In the following code snippet, the LogAspect has been applied at the class level and the CacheAspect has been applied at the method level.

[LogAspect]
public class OrderRepository
{
    [CacheAspect]
    public Task<IEnumerable<Order>> 
    GetAllAsync()
    {
        //Code to return all orders 
        //from the database
    }

    public Task SaveAsync(Order order)
    {
        //Code to save an order 
        //object to the database
    }
}

Implementing Cross-Cutting Concerns without AOP

If you didn't use AOP here, the OrderRepository class would have looked like this:

public class OrderRepository
{
    public Task<IEnumerable<Order>> 
    GetAllAsync()
    {
        //Code to return all orders 
        //from the cache or the 
        //underlying database
    }

    public Task SaveAsync(Order order)
    {
        //Code to save an order object 
        //to the database and cache it
    }
}

So, you might have to explicitly write your code to call the respective methods of the LogAspect and CacheAspect classes to retrieve data from the cache or persist data into the cache. Most importantly, you'd have had to write such code at several places in your application wherever these aspects were used, resulting in boilerplate code, which is difficult to manage and maintain over a period of time.

When Should I Use AOP?

You can use AOP in for one or more of the following:

  • Logging: You can eliminate boilerplate code to leverage AOP to log data in your application.
  • Caching: You can leverage AOP to cache frequently used, relatively stale data.
  • Validation: You can use AOP to validate input data by enforcing validation rules.
  • Security: You can use AOP to enforce security policies in your application in a consistent manner.
  • Transaction Management: You can take advantage of AOP to implement transaction management in your application.
  • Performance Monitoring: You can use AOP to identify performance and scalability bottlenecks in your application.

Introducing Autofac

Autofac is a lean, open-source, Inversion of Control (IoC) container used for resolving dependencies in .NET Framework and .NET Core applications. You can use it to build applications using .NET Framework or .NET Core, and such applications are maintainable, manageable, adaptable, and loosely coupled. Autofac provides support for interceptors; using them lets you inject your logic before and after method calls, thereby enabling you to implement AOP.

Key Features

Here are the key features of Autofac:

  • Component Registration: Autofac supports the registration of components and their dependencies and can resolve dependencies for registered components if needed.
  • Extensibility: Autofac supports a variety of extension points, including customized registration sources, middleware, and decorators.
  • Modular Design: In Autofac, you can organize component registrations into modules, which can be loaded and unloaded dynamically, thereby promoting the modularity and maintainability of applications.
  • Inversion of Control (IoC): Autofac adheres to the IoC principle, enabling you to define how dependencies are resolved and managed in applicati**ons.
  • Lifetime Management: Autofac supports managing the lifetime of container objects for a particular request, including singleton, transient, and scoped lifetimes.
  • Interception: Autofac supports interception, enabling you to intercept method calls on registered components and apply cross-cutting concerns such as logging, caching, or security.
  • Integration with Frameworks: Autofac integrates seamlessly with .NET and .NET Core frameworks, thereby making it a preferred choice for implementing dependency injection.

What Are Interceptors?

In Autofac, interceptors are components that enable you to add behavior to a class, interface, or configuration without modifying its implementation. Using Autofac, you can intercept method calls and add custom behavior using interception frameworks such as Castle DynamicProxy. In Autofac, interceptors are implemented using the Castle DynamicProxy framework, which creates a proxy object that intercepts method calls at runtime and can modify them before passing them along to the target object.

What Is an Autofac Container? Why Is It Needed?

An Autofac container is used to manage the lifecycle of objects, resolve dependencies, and ensure the creation and proper disposal of objects when they're no longer needed by your application. You can create dependencies by defining the required interfaces or concrete types, which are then registered in the Autofac container to create a mapping between each dependency and its corresponding implementation.

The Autofac container resolves the dependencies based on the provided mappings and provides the corresponding instances when the application needs them. For example, if your application needs a service instance, you can declare an interface for it and then develop a class that implements it. You can then register this implementation in the Autofac container and use one of the various injection strategies to resolve the dependency as needed in the application.

Benefits of Autofac Over the Built-In DI Container

Autofac provides several benefits compared to the built-in dependency injection (DI) container:

  • Flexibility: The built-in IoC container in ASP.NET Core provides limited support for dependency injection as you can only use constructor injection with it. However, you can leverage Autofac to implement both constructor and property injection.
  • Lifetime management: The Autofac lifetime management system lets you control the lifecycle of your objects and prevent memory leaks by using the singleton instance per dependency, and instance per lifetime scope options.
  • Testing: Autofac makes making mock objects and stubs easy so that you can test your code seamlessly.
  • Third-party integration: Autofac supports integration with popular third-party libraries, like Serilog, AutoMapper, and MediatR. Integrating these libraries into your application and configuring them with Autofac is easy.
  • Performance: Autofac is fast, easily customizable, and leverages lazy loading and compiled expressions to optimize object creation and resolve dependencies.

What Is a Dynamic Proxy?

A dynamic proxy is a programming approach to create a proxy object at runtime, which, in turn, acts as an intermediary between a real object and the client. Using this technique, you can attach aspects to objects dynamically, such as at runtime, without altering their source code and implement cross-cutting concerns such as transaction management, logging, and caching.

Building a Memory Cache Interceptor Using Autofac

A memory cache interceptor is a component that can intercept method calls and cache the results in memory. This helps you optimize the performance by decreasing the need to retrieve the same piece of data repeatedly. In this section, you'll implement a memory cache interceptor. You'll follow the following steps to build a memory cache interceptor:

  1. Create a model class.
  2. Create the data context.
  3. Prepare the seed data.
  4. Create a memory cache interceptor.
  5. Create and configure the Autofac container.
  6. Create the repository class.
  7. Create the controller class.
  8. Leverage the cache interceptor in the controller class.
  9. Run the application.

Create a New ASP.NET Core MVC 8 Project in Visual Studio 2022

You can create a project in Visual Studio 2022 in several ways. When you launch Visual Studio 2022, you'll see the Start window. You can choose “Continue without code” to launch the main screen of the Visual Studio 2022 IDE.

To create a new ASP.NET Core MVC 8 Project in Visual Studio 2022:

  1. Start the Visual Studio 2022 IDE.
  2. In the “Create a new project” window, select “ASP.NET Core Web App (Model-View-Controller)” and click Next to move on.
  3. Specify the project name and the path where it should be created in the “Configure your new project” window.
  4. If you want the solution file and project to be created in the same directory, you can optionally check the “Place solution and project in the same directory” checkbox. Click Next to move on.
  5. In the next screen, specify the target framework and authentication type as well. Ensure that the “Configure for HTTPS,” “Enable Docker,” and “Do not use top-level statements” checkboxes are unchecked because you won't use any of these in this example.
  6. Click Create to complete the process.

A new ASP.NET Core MVC project is created. You’ll use this project in the sections that follow.

Install NuGet Package(s)

First off, let's install the necessary NuGet Package(s). To install the required package into your project, right-click on the solution and then select Manage NuGet Packages for Solution…. Now search for the package named PostSharp in the search box and install it. Alternatively, you can type the commands shown below at the NuGet Package Manager command prompt:

PM> Install-Package Autofac
PM> Install-Package Autofac.Extensions.DependencyInjection
PM> Install-Package Autofac.Extras.DynamicProxy

You can also install this package by executing the following commands at the Windows Shell:

dotnet add package Autofac
dotnet add package Autofac.Extensions.DependencyInjection
dotnet add package Autofac.Extras.DynamicProxy

Create the Model Class

Crate a new class named Customer in a file named Customer.cs inside the Models folder of the web application project you created earlier and write the following code in there:

namespace OrderProcessingSystem.Models
{
    public class Customer
    {
        public Guid Id { get; set; }
        public string FirstName { get; set; } = default!;
        public string LastName { get; set; } = default!;
        public string Address { get; set; } = default!;
        public string Email { get; set; } = default!;
        public string Phone { get; set; } = default!;
    }
}

Create the Data Context

In Entity Framework Core (EF Core), a data context is a component used by an application to interact with the database and manage database connections, and to query and persist data in the database. You'll now create a data context class named CustomerDbContext. To create a data context, create a class that extends the DbContext class of EF Core, as shown in Listing 1.

Listing 1: The CustomerDbContext class

namespace OrderProcessingSystem.DataAccess 
{
    public class CustomerDbContext : DbContext
    {
        public CustomerDbContext
           (DbContextOptions <ProductDbContext> options) : base(options)
        {
        }
        protected override void OnConfiguring
           (DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseInMemoryDatabase
              (databaseName: "OrderManagementSystem");
        }
        public DbSet<Customer> Customers { get; set; }
        protected override void 
        OnModelCreating(ModelBuilder modelBuilder)
        {
            var data = GenerateCustomerData();
            modelBuilder.Entity<Customer>().HasData(data);
        }
        private Customer[] GenerateCustomerData()
        {
            var customerFaker = new Faker<Customer>()
                .RuleFor(e => e.Id, _ => Guid.NewGuid())
                .RuleFor(e => e.FirstName, f => f.Name.FirstName())
                .RuleFor(e => e.LastName, f => f.Name.LastName())
                .RuleFor(e => e.Address, f => f.Address.FullAddress())
                .RuleFor(e => e.Email, (f, e) => f.Internet.Email
                    (e.FirstName, e.LastName))
                .RuleFor(e => e.Phone, f => f.Phone.PhoneNumber());

            return customerFaker.Generate(count: 5).ToArray();
        }
    }
}

Create the Index View for the Customer Model

In ASP.NET Core MVC, views are used to present data to the user in a way that's meaningful. In this section, you'll create an Index view for the Customer model. To build the Index view for the Customer model, follow these steps:

  1. Import model data onto the view to read and extract data pertaining to the Customer model.
  2. Access the properties of the Customer model.
  3. Iterate through the Customer data using the @foreach loop.
  4. Access the Customer data inside the loop and display it in the view using @Html.DisplayFor.

The complete source code of the customer view is given in Listing 2.

Listing 2: Index View of the Customer Model

@model IEnumerable
<OrderProcessingSystem.Models.Customer>
@{
    ViewData["Title"] = "Customer View";
}

<h1>Customer Details</h1>

<table class = "table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.FirstName)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.LastName)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Address)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.FirstName)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.LastName)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Address)
                </td>
            </tr>
        }
    </tbody>
</table>

Note that I've omitted the ID column for brevity in the preceding code of the Index view of the Customer model.

Create the CustomerRepository

The customer repository will comprised of the ICustomerRepository interface and the CustomerRepository class. The code snippet given below shows the ICustomerRepository interface.

using OrderProcessingSystem.Models;

namespace OrderProcessingSystem.DataAccess
{
   public interface ICustomerRepository
    {
        public List<Customer> GetAll();
        public Customer GetById(int id);
    }
}

The CustomerRepository class implements the ICustomerRepository interface, as shown below.

using OrderProcessingSystem.Models;
using Microsoft.EntityFrameworkCore;

namespace OrderProcessingSystem.DataAccess
{
    public class CustomerRepository : ICustomerRepository
    {
        private readonly CustomerDbContext _context;
        public CustomerRepository()
        {
            var options = new DbContextOptionsBuilder<ProductDbContext>()
                .UseInMemoryDatabase("OrderManagementSystem").Options;
            _context = new CustomerDbContext(options);
            _context.Database.EnsureCreated();
        }
        public List<Customer> GetAll() => 

Create the MemoryCacheInterceptor

To create a custom interceptor, create a class that implements the IInterceptor interface, as shown in Listing 3.

Listing 3: The MemoryCacheInterceptor class

public class MemoryCacheInterceptor : IInterceptor
{
  private IMemoryCache _memoryCache;

  public MemoryCacheInterceptor(IMemoryCache memoryCache)
  {
      _memoryCache = memoryCache;
  }

  public void Intercept(IInvocation invocation)
  {
      //Write your custom code here 
      //to intercept methods at runtime
  }
}

In Listing 3, write your custom code to intercept methods at runtime. It should be noted that this code, that is, the source code you need to write inside the Intercept method, will be executed when a method is intercepted. This means that this code will be executed before and after the intercepted method is invoked.

The complete source code of the MemoryCacheInterceptor is given in Listing 4.

Listing 4: The complete source of MemoryCacheInterceptor class

public class MemoryCacheInterceptor : IInterceptor
{
  private readonly IMemoryCache _memoryCache;
  private TimeSpan cacheDuration = TimeSpan.FromSeconds(30);

  public MemoryCacheInterceptor(IMemoryCache memoryCache)
  {
     _memoryCache = memoryCache;
  }

  public void Intercept(IInvocation invocation)
  {            
      Type? declaringType = invocation.Method.DeclaringType;
      string? methodName = invocation.Method.Name;
      IEnumerable<string?> arguments = invocation.Arguments.Select(
          arg => (arg ?? string.Empty).ToString());
      string? commaSeperatedArguments = string.Join(", ",  arguments);
      string? cacheKey =  $" {declaringType}|{methodName}
          | {commaSeperatedArguments}";

      if (!_memoryCache.TryGetValue (cacheKey, out object? returnValue))
      {
          invocation.Proceed();
          returnValue = invocation.ReturnValue;
          _memoryCache.Set(cacheKey, returnValue, cacheDuration);
      }
      else
      {
          invocation.ReturnValue = returnValue;
      }
   }
}

Register the MemoryCacheInterceptor

To register the MemoryCacheInterceptor, i.e., wire up the interceptor to the Autofac container, you can use the following code.

builder.RegisterType<MemoryCacheInterceptor>();
builder.RegisterType<CustomerRepository>()
    .As<ICustomerRepository>()
    .InstancePerDependency()
    .EnableInterfaceInterceptors()
    .InterceptedBy(typeof(MemoryCacheInterceptor));

Note that you can also register multiple interceptors as shown in the code snippet given below.

builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()).
    ConfigureContainer<ContainerBuilder>(builder =>
{
     builder.RegisterType<MemoryCacheInterceptor>();
     builder.RegisterType<CustomerRepository>()
        .As<ICustomerRepository>()
        .InstancePerDependency()
        .EnableInterfaceInterceptors()
        .InterceptedBy(typeof(LoggingInterceptor))
        .InterceptedBy(typeof(TransactionM anagementInterceptor))
        .InterceptedBy(typeof(MemoryCacheInterceptor));
});

Create an Autofac Container

Let's now create an Autofac container to register the ICustomerRepository interface and its implementation type, i.e., the CustomerRepository class. To create a container, leverage the ContainerBuilder class. Note that before you start using your container, you must build it using the Build method. When you invoke the Build method, the container validates the registrations and then creates the necessary components and their dependencies.

You'll create a new static class here that contains a static method to encapsulate the logic of creating the container, registering types, and specifying the interceptors. Listing 5 shows how you can achieve this by creating a static method called BuildContainer inside a static class named ContainerHelper.

Listing 5: The ContainerHelper class

public static class ContainerHelper
{
    public static WebApplication
    BuildContainer(WebApplicationBuilder builder)
    {

        builder.Host.UseServiceProviderFactory
          (new AutofacServiceProviderFactory()).
            ConfigureContainer<ContainerBuilder>(builder =>
        {
            builder.RegisterType<MemoryCacheInterceptor>();
            builder.RegisterType<CustomerRepository>()
                .As<ICustomerRepository>()
                .InstancePerDependency()
                .EnableInterfaceInterceptors()
                .InterceptedBy(typeof(MemoryCacheInterceptor));
        });

        return builder.Build();
    }
}

Call the BuildContainer method of the ContainerHelper class in the Program.cs file, as shown in this code snippet:

var app = ContainerHelper.BuildContainer(builder);

Create the CustomerController Class

Create a new ASP.NET Core MVC controller named CustomerController in a file named CustomerController.cs inside the Controllers folder of the project. The following piece of code shows how you can take advantage of constructor injection to create an instance of type ICustomerRepository and then call the GetAll method of it inside the Index action method of the controller.

private readonly ICustomerRepository _customerRepository;
public CustomerController(ICustomerRepository customerRepository, 
  IMemoryCache memoryCache)
{
     _customerRepository = customerRepository;
}
[LoggingAspect]
public ActionResult Index()
{
      return View(_customerRepository.GetAll());
}

The complete source code of the CustomerController class is given in Listing 6.

Listing 6: The CustomerController class

public class CustomerController : Controller
{
    private readonly ICustomerRepository _customerRepository;
    public CustomerController (ICustomerRepository customerRepository, 
      IMemoryCache memoryCache)
    {
        _customerRepository = customerRepository;
    }

    public ActionResult Index()
    {
        return View(_customerRepository.GetAll());
    }
    public ActionResult Details(int id)
    {
        return View();
    }
    public ActionResult Create()
    {
        return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Create
    (IFormCollection collection)
    {
        try
        {
            return RedirectToAction(nameof(Index));
        }
        catch
        {
            return View();
        }
    }
    public ActionResult Edit(int id)
    {
        return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Edit(int id, 
    IFormCollection collection)
    {
        try
        {
            return RedirectToAction(nameof(Index));
        }
        catch
        {
            return View();
        }
    }

    public ActionResult Delete(int id)
    {
        return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Delete
    (int id, IFormCollection collection)
    {
        try
        {
            return RedirectToAction(nameof(Index));
        }
        catch
        {
            return View();
        }
    }
}

The Program.cs File

The complete source code of the Program.cs file is given in Listing 7.

Listing 7: The Program.cs file

var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddMemoryCache();

builder.Services.AddControllersWithViews();

var app = ContainerHelper.BuildContainer(builder);

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();

When you execute the application and browse the /Customer endpoint, the customer records are displayed at the web browser, as shown in Figure 4.

Figure 4: Displaying the customer records in the web browser
Figure 4: Displaying the customer records in the web browser

Because caching has been enabled, and because you've already registered a memory cache interceptor, the customer data is fetched from the cache and not from the database for all subsequent calls to the same endpoint. Figure 5 shows how the breakpoint set in the Intercept method of the MemoryCacheInterceptor class is hit when you refresh the web page.

Figure 5: The Intercept method of the MemoryCacheInterceptor is called.
Figure 5: The Intercept method of the MemoryCacheInterceptor is called.

Programming AOP Using PostSharp

Typically, developers find it challenging to handle cross-cutting concerns such as logging, caching, validation, and error handling because the code is often entangled throughout the codebase. This leads to duplicate code and a lack of code modularity. For example, you might require logging at several places in an application. Here's where PostSharp can help, by automatically addressing these concerns, reducing boilerplate code, and increasing code readability and modularity.

PostSharp works by post-processing compiler output to add aspects and make changes to the intermediate assembly. After compilation, PostSharp reads and disassembles the intermediate assembly, applies the necessary transformations and validations depending on the provided aspects, and saves the updated assembly to the file system.

When using PostSharp, you may create custom aspects that encapsulate reusable code and apply these aspects to particular methods, classes, or attributes in your code. PostSharp incorporates these features into the generated code, either at build time or at runtime, and integrates them directly into the assembly. PostSharp supports two types of weaving: compile-time and run-time.

The compile-time weaving technique guarantees that the aspects are built into the assembly, resulting in reduced runtime cost. This implies that when your application starts, the aspects are already applied to the code, eliminating the need for extra processing or overhead. In runtime weaving, PostSharp enables applying aspects in a more dynamic manner, enabling you to deploy components like application plug-ins or extensible frameworks at runtime. In other words, although the aspects are integrated directly into the compiled code during compilation in compile-time weaving, in runtime-weaving, these aspects are applied at specified points of the application's code at runtime.

A Real-World Use Case: Store Performance Metadata Using AOP

You'll now implement a simple application that demonstrates how you can retrieve and store application performance metadata in the database. You'll initially build the Product module of a simple OrderProcessing application. You'll capture performance metadata from this application while it's in execution. The source code of this module is comprised of the following classes and interfaces:

  • Product class
  • IProductRepository interface
  • ProductRepository class
  • ProductController class

Create the Model Class

Create a new class named Product in a file having the same name with a .cs extension and write the following code in there:

public class Product
{
   public int Id { get; set; }
   public string Name { get; set; } = string.Empty;
   public string Description { get; set; } = string.Empty;
   public decimal Price { get; set; } = decimal.Zero;
}

Create the Data Context

Next, create the data context class to interact with the database. To do this, create a new class named ProductDbContext that extends the DbContext class of EF Core and write the code from Listing 8 in there.

Listing 8: The ProductDbContext class

public class ProductDbContext : DbContext
{
     public ProductDbContext (DbContextOptions
       <ProductDbContext>  options) : base(options)
     {
     }
     protected override void OnConfiguring 
       (DbContextOptionsBuilder optionsBuilder)
     {
         optionsBuilder.UseInMemoryDatabase (databaseName: "ProductDb");
     }
     public DbSet<Product> Products { get; set; }
     protected override void OnModelCreating (ModelBuilder modelBuilder)
     {
         var products = GenerateProductData();
         modelBuilder.Entity<Product>().HasData(products);
     }
     private Product[] GenerateProductData()
     {
         var productId = 1;

         var productFaker = new Faker<Product>()
             .RuleFor(x => x.Id, f => productId++)
             .RuleFor(x => x.Name, f => f.Commerce.ProductName())
             .RuleFor(x => x.Description, f => f.Commerce.ProductDescription())
             .RuleFor(x => x.Price, f => Math.Round(f.Random.Decimal 
               (1000, 5000), 2));

         return productFaker.Generate(count: 5).ToArray();
     }
 }

Create the ProductRepository Class

Now, create a new class named ProductRepository in a file having the same name with a .cs extension. Now write the following code in there:

public class ProductRepository : IProductRepository
{       
        
}

The ProductRepository class illustrated in the code snippet below, implements the methods of the IProductRepository interface. Here is how the IProductRepository interface should look:

public interface IProductRepository
{
    public List<Product> GetAll();
    public Product GetById(int id);
    public void Create(Product product);
    public void Delete(Product product);
}

The complete source code of the ProductRepository class is given in Listing 9.

Listing 9: The ProductRepository Class

public class ProductRepository : IProductRepository
{
    private readonly ProductDbContext _context;
    public ProductRepository()
    {
        var options = new DbContextOptionsBuilder <ProductDbContext>()
            .UseInMemoryDatabase("OrderManagementSystem").Options;

        _context = new ProductDbContext(options);
        _context.Database.EnsureCreated();
    }
    public List<Product> GetAll() =>  _context.Products.ToList<Product>();
    public Product GetById(int id) => _context.Products.SingleOrDefault
      (p => p.Id == id);

    public void Create(Product product) => _context.Products.Add(product);
    public void Delete(Product product) => _context.Products.Remove(product);
}

Create the ProductController Class

Now, create a new controller named ProductController in the Controllers folder of the project. The following code snippet shows how you can take advantage of constructor injection to create an instance of the ProductRepository class and then use it to retrieve all product records from the database in the Index action method.

public class ProductController: Controller 
{
   private readonly IProductRepository _productRepository;
  
   public ProductController (IProductRepository productRepository, 
     IMemoryCache memoryCache) 
  {
      _productRepository = productRepository;
  }

  public ActionResult Index() 
  {
      return View(_productRepository.GetAll());
  }

  //Other action methods
}

Listing 10 shows the complete source of the ProductController class.

Listing 10: The ProductController class

public class ProductController : Controller
{
    private readonly IProductRepository _productRepository;
    public ProductController(IProductRepository productRepository, 
      IMemoryCache memoryCache)
    {
         _productRepository = productRepository;
    }
    public ActionResult Index()
    {
         return View(_productRepository.GetAll());
    }
    public ActionResult Details(int id)
    {
         return View();
    }
    public ActionResult Create()
    {
         return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Create(IFormCollection collection)
    {
        try
        {
            return RedirectToAction(nameof(Index));
        }
        catch
        {
            return View();
        }
    }
    public ActionResult Edit(int id)
    {
        return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Edit(int id, IFormCollection collection)
    {
        try
        {
            return RedirectToAction(nameof(Index));
        }
        catch
        {
            return View();
        }
    }
    public ActionResult Delete(int id)
    {
         return View();
    }

     [HttpPost]
     [ValidateAntiForgeryToken]
     public ActionResult Delete(int id, 
     IFormCollection collection)
     {
         try
         {
             return RedirectToAction(nameof(Index));
         }
         catch
         {
             return View();
         }
     }
}

Store Performance Metadata into the Database

In this section, I'll examine how to capture and store application performance metadata in a database. You'll leverage AOP to retrieve and store performance monitoring data in a database. Note that you'll store the CPU and memory usage details in a database as well as the method execution time of an action method in another database. For the sake of simplicity, I'll use SqlLite databases in this example.

Create the ExecutionTimeMetadata Model

Create a new class named ExecutionTimeMetadata in a file named ExecutionTimeMetadata.cs and write the following code in there.

public class ExecutionTimeMetadata
{
   [Key]
   [DatabaseGenerated (DatabaseGeneratedOption.Identity)]
   public int Id { get; set; }
   public string MethodName { get; set; }
   public string DeclaringType { get; set; }
   public long ExecutionTime { get; set; }
}

You'll use this class to store method execution metadata in memory.

Create the ExecutionTimeDbContext Class

The ExecutionTimeDbContext class is used to connect to a SqlLite database and store the method execution metadata in it, as shown in Listing 11.

Listing 11: The ExecutionTimeDbContext class

public class ExecutionTimeDbContext : DbContext
{
    public ExecutionTimeDbContext (DbContextOptions
      <ExecutionTimeDbContext> options) : base(options)
    {
    }
    protected override void OnConfiguring (DbContextOptionsBuilder options)
      => options.UseSqlite ($"Data Source=ExecutionTimeMonitor.db;");
    public DbSet<ExecutionTimeMetadata> ExecutionTimeMetadatas { get; set; }
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
         modelBuilder.Entity <ExecutionTimeMetadata> (entity =>
         {
             entity.HasKey(e => e.Id);
             entity.Property(e => e.MethodName).HasColumnType("TEXT");
             entity.Property(e => e.DeclaringType).HasColumnType("TEXT");
             entity.Property(e => e.ExecutionTime).HasColumnType("LONG");
         });
     }
}

Create the ExecutionTimeAspect class

The ExectionTimeAspect class is attached to a method call at runtime. It captures and stores execution time of a method in a SqlLite database. Listing 12 shows the complete source code of the ExecutionTimeAspect class.

Listing 12: The ExecutionTimeAspect class

[PSerializable]
 public class ExecutionTimeAspect : OnMethodBoundaryAspect
 {
     private ExecutionTimeDbContext? _context;

     [NonSerialized]
     Stopwatch stopWatch;
     public override void OnEntry(MethodExecutionArgs args)
     {
         stopWatch = Stopwatch.StartNew();
         base.OnEntry(args);
     }
     public override void OnExit (MethodExecutionArgs args)
     {
         var methodBase = new StackTrace().GetFrame(1).GetMethod();
         string typeName = methodBase.DeclaringType.Name;
         string methodName = methodBase.Name;
         long executionTime = stopWatch.ElapsedMilliseconds;
         Console.WriteLine($"The method {methodName} executed 
           in {executionTime} seconds.");

         var options = new DbContextOptionsBuilder
           <ExecutionTimeDbContext>()
             .UseSqlite($"Data Source=ExecutionTimeMonitor.db")
             .Options;

         _context = new ExecutionTimeDbContext(options);
         _context.Database.EnsureCreated();

         _context.ExecutionTimeMetadatas.Add(new ExecutionTimeMetadata()
         {
             MethodName = methodName,
             DeclaringType = typeName,
             ExecutionTime = executionTime,
         });
         
         _context.SaveChanges();
         base.OnExit(args);
     }
 }

Lastly, apply the ExecutionTimeAspect to a method of your choice to log the method execution time. The code snippet given below shows how you can apply this aspect to the Index method of the ProductController class.

[ExecutionTimeAspect]
 public ActionResult Index()
 {
     return View(_productRepository.GetAll());
 }

Figure 6 shows the execution time (in milliseconds) of the Index method of the ProductController class.

Figure 6: Displaying the execution time of the Index action method
Figure 6: Displaying the execution time of the Index action method
public class PerformanceMetadata
{
  [Key]
  [DatabaseGenerated (DatabaseGeneratedOption.Identity)]
  public int Id { get; set; }
  public float CpuUsage { get; set; }
  public float MemoryUsage { get; set; }
}

Create the PerformanceDbContext

The PerformanceDbContext class connects to a SqlLite database named PerformanceMonitor.db to persist performance metadata. In the OnModelCreating method, you specify the primary key and the database table columns you need. Listing 13 is the complete source code of the PerformanceDbContext class.

Listing 13: The PerformanceDbContext class

public class PerformanceDbContext : DbContext
{
    public PerformanceDbContext(DbContextOptions
      <PerformanceDbContext> options) : base(options)
    {
    }
    protected override void OnConfiguring
      (DbContextOptionsBuilder options) => options.UseSqlite
      ($"Data Source=PerformanceMonitor.db;");
    public DbSet<PerformanceMetadata> PerformanceMetadatas { get; set; }
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity <PerformanceMetadata>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.CpuUsage).HasColumnType("FLOAT");
            entity.Property(e => e.MemoryUsage).HasColumnType("FLOAT");
        });
    }
}

Retrieve Performance Metadata

In this section, I'll examine how you can take advantage of WMI to retrieve system information from your computer. WMI is an acronym for Windows Management Instrumentation, a COM-based Microsoft technology used to retrieve hardware information. To capture application performance metadata, i.e., the CPU usage and memory usage in this example, create a new class called ApplicationPerformanceUtility in a file named ApplicationPerformanceUtility.cs and write the code given in Listing 14 in there.

Listing 14: The ApplicationPerformanceUtility class

public class ApplicationPerformanceUtility
{
    public static float GetCpuUsage()
    {
        var managementObjectSearcher = new ManagementObjectSearcher
          ("SELECT * FROM Win32_PerfFormattedData_PerfOS_Processor");
        var managementObjectCollection = managementObjectSearcher.Get();

        float totalUsage = 0;
        foreach (var managementBaseObject in managementObjectCollection)
        {
            totalUsage += float.Parse(managementBaseObject
              ["PercentProcessorTime"].ToString());
        }

        return totalUsage / managementObjectCollection.Count;
    }

    public static float GetMemoryUsage()
    {
        ManagementObjectSearcher 
        managementObjectSearcher = new ManagementObjectSearcher
          ("SELECT * FROM Win32_OperatingSystem");
        var managementObject = managementObjectSearcher.
          Get().Cast<ManagementObject>().Select(m => new
        {
            TotalVisibleMemorySize = m["TotalVisibleMemorySize"],
            FreeMemory = m["FreePhysicalMemory"]
        }).FirstOrDefault();

        var freeMemory = (ulong)managementObject.FreeMemory;
        var totalMemory = (ulong)managementObject.TotalVisibleMemorySize;
        return (totalMemory - freeMemory) / (float)totalMemory * 100;
    }
}

Create the LoggingAspect Class

[PSerializable]
public class LoggingAspect : OnMethodBoundaryAspect
{
    public override void OnEntry(MethodExecutionArgs args)
    {
       
    }
    public override void OnSuccess(MethodExecutionArgs args)
    {
    }
    public override void OnException(MethodExecutionArgs args)
    {
    }
    public override void OnExit(MethodExecutionArgs args)
    {
    }
}
  • OnEntry: This method is executed before the method on which the aspect is applied is executed.
  • OnSuccess: This method is executed after the method on which the aspect is applied is executed and when it has returned successfully.
  • OnException: This method is executed after the method on which the aspect is applied has thrown an exception.
  • OnExit: This method is executed after the method on which the aspect is applied has completed its execution with or without an exception.
public override void OnExit
 (MethodExecutionArgs args)
{
  var typeName = args.Method.DeclaringType.Name;
  var methodName = args.Method.Name;
  Console.WriteLine ($"{methodName} method of {typeName} executed."); 
}

When you execute the application and invoke an endpoint on which the LoggingAspect attribute has been applied, the performance metadata is captured and stored in the configured SqlLite database, as shown in Figure 7.

Conclusion

Procedural programming, object-oriented programming (OOP), and aspect-oriented programming (AOP) are widely used programming paradigms with distinctly different characteristics. The former emphasizes objects and their interactions and the latter paradigm isolates and modularizes the cross-cutting concerns. Conventional object-oriented methods can pose significant challenges when managing, maintaining, or modifying an application's code because the concerns are entangled and intertwined with the core logic.

In this article, I've examined how you can capture and store performance metadata in a database. You've used WMI to capture CPU and memory usage data. The CPU and memory usage details have been captured during a method invocation. You can leverage a background service to capture CPU and Memory usage data and store it in a database at frequent intervals of time.