Building a High-Performance API with .NET Core

In the world of modern web development, creating high-performance APIs is crucial for delivering excellent user experiences and efficiently managing system resources. This blog post will provide an in-depth exploration of the implementation of a performance-optimized API using .NET Core, diving deep into various techniques and best practices.

Building a High-Performance API with - .NET Core

1. Efficient Data Access

Efficient data access is the cornerstone of a high-performance API. We’ve implemented two primary strategies for data access: Entity Framework Core for complex queries and Dapper for optimized read operations.

Entity Framework Core for Complex Queries

Entity Framework Core (EF Core) is our ORM of choice for complex queries involving multiple related entities. It provides a powerful and flexible way to interact with our database.

public async Task<CustomerViewModel> GetCustomerWithPurchases(int customerId, CancellationToken cancellationToken)
{
    var customer = await _dbContext.Customers
        .Where(c => c.Id == customerId)
        .Select(c => new CustomerViewModel
        {
            Id = c.Id,
            FirstName = c.FirstName,
            LastName = c.LastName,
            Email = c.Email,
            PhoneNumber = c.PhoneNumber,
            Created = c.Created,
            Updated = c.Updated,
            Purchases = c.Purchases.Select(p => new PurchaseViewModel
            {
                Id = p.Id,
                PurchaseDate = p.PurchaseDate,
                TotalAmount = p.TotalAmount,
                CustomerId = p.CustomerId,
                CustomerName = $"{p.Customer.FirstName} {p.Customer.LastName}",
                PurchaseDetails = p.PurchaseDetails.Select(pd => new PurchaseDetailViewModel
                {
                    Id = pd.Id,
                    Quantity = pd.Quantity,
                    Price = pd.Price,
                    TotalAmount = pd.TotalAmount,
                    ProductId = pd.ProductId,
                    ProductName = pd.Product.Name,
                    Created = pd.Created,
                    Updated = pd.Updated
                }).ToList(),
                Created = p.Created,
                Updated = p.Updated
            }).ToList()
        })
        .AsNoTracking()
        .FirstOrDefaultAsync(cancellationToken);

    if (customer == null)
    {
        throw new NotFoundException("Customer not found");
    }

    return customer;
}

Key points about this implementation:

  1. Complex Query: This query fetches a customer along with their purchases and purchase details in a single database roundtrip.
  2. Projection: We use Select to project the results directly into our view models. This approach is more efficient than fetching entire entities when we only need specific properties.
  3. AsNoTracking: By using AsNoTracking(), we tell EF Core not to track these entities in its change tracker. This is perfect for read-only scenarios and improves performance.
  4. Async: We use async methods (FirstOrDefaultAsync) to avoid blocking threads while waiting for database operations.
  5. Cancellation Token: Including a CancellationToken allows for graceful cancellation of long-running queries.

Dapper for Optimized Read Operations

While EF Core is excellent for complex queries, sometimes we need more control over the SQL being executed. This is where Dapper, a lightweight ORM, comes in handy.

public async Task<IEnumerable<PurchaseViewModel>> GetPurchasesByCustomer(int customerId)
{
    var sql = @"
        SELECT p.""Id"", p.""PurchaseDate"", p.""TotalAmount"",
               p.""CustomerId"", c.""FirstName"", c.""LastName"", c.""Email"", c.""PhoneNumber"",
               pd.""Id"" AS ""PurchaseDetailId"", pd.""ProductId"", pd.""Quantity"", pd.""Price"", 
               pr.""Name"" AS ""ProductName""
        FROM ""Purchases"" p
        JOIN ""Customers"" c ON p.""CustomerId"" = c.""Id""
        LEFT JOIN ""PurchaseDetails"" pd ON pd.""PurchaseId"" = p.""Id""
        LEFT JOIN ""Products"" pr ON pd.""ProductId"" = pr.""Id""
        WHERE p.""CustomerId"" = @CustomerId;
        ";

    var connection = _dbContext.Database.GetDbConnection();
    await connection.OpenAsync();

    try
    {
        var purchaseDict = new Dictionary<int, PurchaseViewModel>();

        var result = await connection.QueryAsync<PurchaseViewModel, CustomerViewModel, PurchaseDetailViewModel, PurchaseViewModel>(
            sql,
            (purchase, customer, purchaseDetail) =>
            {
                if (!purchaseDict.TryGetValue(purchase.Id, out var currentPurchase))
                {
                    currentPurchase = purchase;
                    currentPurchase.Customer = customer;
                    currentPurchase.PurchaseDetails = new List<PurchaseDetailViewModel>();
                    purchaseDict.Add(currentPurchase.Id, currentPurchase);
                }

                if (purchaseDetail != null)
                {
                    currentPurchase.PurchaseDetails.Add(purchaseDetail);
                }

                return currentPurchase;
            },
            new { CustomerId = customerId },
            splitOn: "CustomerId,PurchaseDetailId"
        );

        return purchaseDict.Values;
    }
    finally
    {
        await connection.CloseAsync();
    }
}

Key points about this implementation:

  1. Raw SQL: We have full control over the SQL query, allowing for optimized joins and selections.
  2. Multi-Mapping: Dapper’s multi-mapping feature allows us to map a single row to multiple objects, perfect for handling one-to-many relationships.
  3. Dictionary for Deduplication: We use a dictionary to ensure we don’t create duplicate PurchaseViewModel objects for the same purchase.
  4. Connection Management: We explicitly open and close the database connection, ensuring efficient use of database resources.

2. Caching Strategies

Caching is a powerful technique for improving API performance. We’ve implemented both in-memory and distributed caching to reduce database load and improve response times.

In-Memory Caching

In-memory caching is used for frequently accessed data that doesn’t change often and doesn’t need to be shared across multiple server instances.

public async Task<CustomerViewModel> GetCustomerWithPurchases(int customerId, CancellationToken cancellationToken)
{
    var cacheKey = $"Customer_{customerId}";
    var _cacheService = _cacheServiceFactory.GetCacheService(CacheType.Memory);

    var cachedCustomer = await _cacheService.GetAsync<CustomerViewModel>(cacheKey);
    if (cachedCustomer != null)
    {
        return cachedCustomer;
    }

    var customer = await _customerRepository.GetCustomerWithPurchases(customerId, cancellationToken);
    if (customer == null)
    {
        throw new NotFoundException("Customer not found");
    }

    var customerViewModel = _mapper.Map<CustomerViewModel>(customer);

    await _cacheService.SetAsync(cacheKey, customerViewModel, TimeSpan.FromMinutes(30));

    return customerViewModel;
}

Key points:

  1. Cache Key: We use a unique key for each customer.
  2. Cache-Aside Pattern: We check the cache first before querying the database.
  3. Expiration: We set an expiration time to ensure the cache doesn’t become stale.

Distributed Caching with Redis

For data that needs to be shared across multiple server instances or for larger datasets, we use Redis as a distributed cache.

public async Task<IEnumerable<PurchaseViewModel>> GetPurchasesByCustomer(int customerId)
{
    var cacheKey = $"PurchasesByCustomer_{customerId}";
    var _cacheService = _cacheServiceFactory.GetCacheService(CacheType.Redis);
    var cachedData = await _cacheService.GetAsync<IEnumerable<PurchaseViewModel>>(cacheKey);

    if (cachedData != null)
        return cachedData;

    var purchaseViewModel = await _purchaseRepository.GetPurchasesByCustomer(customerId);

    if (purchaseViewModel == null)
    {
        throw new NotFoundException("No data found");
    }

    await _cacheService.SetAsync(cacheKey, purchaseViewModel, TimeSpan.FromMinutes(30));

    return purchaseViewModel;
}

Key points:

  1. Cache Service Factory: We use a factory to get the appropriate cache service (Redis in this case).
  2. Serialization: The cache service handles serialization and deserialization of complex objects.
  3. Consistency: Be mindful of cache invalidation strategies to ensure data consistency across your application.

3. Performance Optimizations

Pagination

Pagination is crucial for handling large datasets efficiently. It reduces the amount of data transferred and processed at once, improving both server performance and client-side rendering.

public async Task<PaginatedDataViewModel<T>> GetPaginatedData(int pageNumber, int pageSize, string sortBy, string sortOrder, CancellationToken cancellationToken = default)
{
    var query = _dbContext.Set<T>().AsNoTracking();

    if (!string.IsNullOrEmpty(sortBy))
    {
        var orderByExpression = GetOrderByExpression<T>(sortBy);
        query = sortOrder?.ToLower() == "desc" ? query.OrderByDescending(orderByExpression) : query.OrderBy(orderByExpression);
    }

    var data = await query
        .Skip((pageNumber - 1) * pageSize)
        .Take(pageSize)
        .ToListAsync(cancellationToken);

    var totalCount = await query.CountAsync(cancellationToken);

    return new PaginatedDataViewModel<T>(data, totalCount);
}

Key points:

  1. Generic Method: This method works with any entity type T.
  2. Sorting: Dynamic sorting is implemented based on the provided sortBy and sortOrder parameters.
  3. Skip and Take: EF Core translates these methods into efficient SQL queries.
  4. Total Count: We fetch the total count for accurate pagination information.

4. Clean Architecture

We follow clean architecture principles to ensure our code is modular, testable, and maintainable.

  1. Repository Pattern: We use repositories to abstract data access logic.
  2. Dependency Injection: Services are injected rather than instantiated directly, allowing for easier testing and flexibility.
  3. Separation of Concerns: Business logic, data access, and presentation concerns are kept separate.

5. Data Mapping

AutoMapper is used for efficient object-to-object mapping, reducing boilerplate code, and improving maintainability.

public async Task<CustomerViewModel> GetById(int customerId, CancellationToken cancellationToken)
{
    var customer = await _customerRepository.GetById(customerId, cancellationToken);
    if (customer == null)
    {
        throw new NotFoundException("Customer not found");
    }
    return _mapper.Map<CustomerViewModel>(customer);
}

Key points:

  1. Declarative Mapping: AutoMapper configurations are typically set up once and used throughout the application.
  2. Performance: AutoMapper is designed to be fast and can be further optimized if needed.

6. Data Seeding

For testing and development purposes, we use the Bogus library to generate realistic test data.

public static async Task SeedData(ApplicationDbContext context)
{
    await context.Database.EnsureCreatedAsync();

    if (context.Products.Any())
    {
        return; // DB has been seeded
    }

    var products = new Faker<Product>()
        .RuleFor(p => p.Name, f => f.Commerce.ProductName())
        .RuleFor(p => p.Code, f => f.Commerce.Random.String2(5).ToUpper())
        .RuleFor(p => p.Price, f => (float)Math.Round(f.Finance.Amount(1, 100), 2))
        .RuleFor(p => p.Created, f => f.Date.Recent())
        .RuleFor(p => p.IsActive, true);

    var productList = products.Generate(500);

    await context.Products.AddRangeAsync(productList);
    await context.SaveChangesAsync();
}

Key points:

  1. Realistic Data: Bogus generates data that looks real, improving the quality of testing and development.
  2. Efficiency: Generating and inserting data in batches is more efficient than individual inserts.
  3. Idempotency: The seeding method checks if data already exists, ensuring it doesn’t duplicate data on multiple runs.

Conclusion

Building a high-performance API in .NET Core requires a holistic approach, combining efficient data access strategies, smart caching, optimized queries, and following best practices in software architecture and development.

By leveraging the power of Entity Framework Core and Dapper for data access, implementing both in-memory and distributed caching with Redis, utilizing AutoMapper for efficient object mapping, and following clean architecture principles, we’ve created an API that’s not only fast and efficient but also scalable and maintainable.

Remember that performance optimization is an ongoing process. Regular profiling, identifying bottlenecks, and continuous refinement of your implementation are key to ensuring the best possible performance for your users. Each of these techniques and practices contributes to creating a robust, high-performance API that can handle complex operations efficiently.

Leave a Reply