Building a Robust .NET Core Web API: A Beginner's Guide
Gaurav

Gaurav @gaurav-nandankar

About: Versatile Developer skilled in mobile and web development, UI/UX design, and API integration. Committed to industry best practices and successful project outcomes.

Location:
India
Joined:
Jul 15, 2024

Building a Robust .NET Core Web API: A Beginner's Guide

Publish Date: Apr 22
8 1

So, there I was, reviewing a pull request from one of our junior devs. Bless their heart, they were building a fantastic feature, but the code... let's just say it was a bit... enthusiastic. No generic services, DRY principles were taking a vacation, and the whole thing felt like a house of cards waiting to collapse.

That's when it hit me: we need a guide. A simple, straightforward, "here's how you build a real .NET Core Web API" kind of guide.

This blog post is that guide.

If you're a fresher, or someone with zero knowledge about .NET Core Web APIs, and want to learn how to set up a robust, maintainable, and scalable project, you've come to the right place. We'll cover everything from project setup to essential concepts, data access, messaging, security, and even deployment.

By the end of this post, you'll have a solid foundation for building your own .NET Core Web APIs with confidence. Let's dive in!

Project Setup

First, make sure you have the .NET SDK installed. You can download it from the official .NET website.

Once you have the SDK, you can create a new .NET Core Web API project using the following command in your terminal:

dotnet new webapi -o MyWebApi
cd MyWebApi
Enter fullscreen mode Exit fullscreen mode

This will create a new project with a basic structure. Let's take a look at the key files and folders:

  • Program.cs: This is the entry point of the application. It configures the application's services and middleware.
  • appsettings.json: This file contains configuration settings for the application, such as connection strings and API keys.
  • Controllers: This folder contains the API controllers, which handle incoming requests and return responses.
  • Properties: This folder contains the launchSettings.json file, which configures how the application is launched in development.

To keep our project organized, let's create the following folders:

  • Services: This folder will contain the business logic of the application.
  • Models: This folder will contain the data models.
  • DTOs: This folder will contain the Data Transfer Objects (DTOs).
  • Middleware: This folder will contain custom middleware components.

Your project structure should now look like this:

MyWebApi/
├── Controllers/
├── Services/
├── Models/
├── DTOs/
├── Middleware/
├── appsettings.json
├── Program.cs
└── MyWebApi.csproj
Enter fullscreen mode Exit fullscreen mode

Essential Concepts

Now that we have our project set up, let's dive into some essential concepts that are crucial for building a robust and maintainable .NET Core Web API.

Dependency Injection (DI)

Dependency Injection (DI) is a design pattern that allows us to develop loosely coupled code. In .NET Core, DI is a first-class citizen, and it's heavily used throughout the framework.

To configure DI, we need to register our services with the IServiceCollection in the Program.cs file. Here's an example:

builder.Services.AddTransient<IMyService, MyService>();
Enter fullscreen mode Exit fullscreen mode

This code registers the MyService class as a transient service, which means that a new instance of the service will be created every time it's requested.

There are three main scopes for DI services:

  • Transient: A new instance is created every time it's requested.
  • Scoped: A new instance is created for each request.
  • Singleton: A single instance is created for the lifetime of the application.

GlobalUsings

Global using directives allow you to import namespaces globally across your project, reducing the need to add using statements to individual files.

To use global using directives, create a file named GlobalUsings.cs in your project and add the following code:

global using System;
global using System.Collections.Generic;
global using System.Linq;
Enter fullscreen mode Exit fullscreen mode

Models

Models represent the data entities in your application. They are typically simple classes with properties that map to database columns.

Here's an example of a model class:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public decimal Price { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Data Transfer Objects (DTOs)

Data Transfer Objects (DTOs) are used to transfer data between the API and the client. They help to decouple the API from the data model, allowing you to change the data model without affecting the API.

Here's an example of a DTO class:

public class ProductDTO
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

AutoMapper

AutoMapper is a library that simplifies the process of mapping objects from one type to another. It can be used to map model classes to DTO classes, and vice versa.

To configure AutoMapper, you need to create a mapping profile:

public class MappingProfile : Profile
{
    public MappingProfile()
    {
        CreateMap<Product, ProductDTO>();
        CreateMap<ProductDTO, Product>();
    }
}
Enter fullscreen mode Exit fullscreen mode

Then, register AutoMapper in your Program.cs file:

builder.Services.AddAutoMapper(typeof(MappingProfile));

## Data Access Layer

The data access layer is responsible for interacting with the database. We'll use the Generic Repository and Unit of Work patterns to create a flexible and testable data access layer.

### Generic Repository Pattern

The Generic Repository pattern provides an abstraction over the data access logic, allowing you to easily switch between different data sources without modifying the rest of the application.

First, let's define a generic repository interface:

Enter fullscreen mode Exit fullscreen mode


csharp
public interface IGenericRepository where T : class
{
Task GetByIdAsync(int id);
Task> GetAllAsync();
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
}


Next, let's create a generic repository implementation:

Enter fullscreen mode Exit fullscreen mode


csharp
public class GenericRepository : IGenericRepository where T : class
{
private readonly AppDbContext _context;

public GenericRepository(AppDbContext context)
{
    _context = context;
}

public async Task<T> GetByIdAsync(int id)
{
    return await _context.Set<T>().FindAsync(id);
}

public async Task<IEnumerable<T>> GetAllAsync()
{
    return await _context.Set<T>().ToListAsync();
}

public async Task<T> AddAsync(T entity)
{
    await _context.Set<T>().AddAsync(entity);
    await _context.SaveChangesAsync();
    return entity;
}

public async Task UpdateAsync(T entity)
{
    _context.Set<T>().Update(entity);
    await _context.SaveChangesAsync();
}

public async Task DeleteAsync(T entity)
{
    _context.Set<T>().Remove(entity);
    await _context.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

}


### Unit of Work Pattern

The Unit of Work pattern provides a way to group multiple database operations into a single transaction. This ensures that all operations are either committed or rolled back together, maintaining data consistency.

First, let's define a unit of work interface:

Enter fullscreen mode Exit fullscreen mode


csharp
public interface IUnitOfWork : IDisposable
{
IGenericRepository Products { get; }
Task CompleteAsync();
}


Then, let's create a unit of work implementation:

Enter fullscreen mode Exit fullscreen mode


csharp
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;

public UnitOfWork(AppDbContext context)
{
    _context = context;
    Products = new GenericRepository<Product>(_context);
}

public IGenericRepository<Product> Products { get; private set; }

public async Task<int> CompleteAsync()
{
    return await _context.SaveChangesAsync();
}

public void Dispose()
{
    _context.Dispose();
}
Enter fullscreen mode Exit fullscreen mode

}


### Base Service

Now, let's create a base service that uses the generic repository and unit of work:

Enter fullscreen mode Exit fullscreen mode


csharp
public interface IBaseService where T : class
{
Task> GetAllAsync();
Task GetByIdAsync(int id);
Task AddAsync(TDTO entity);
Task UpdateAsync(int id, TDTO entity);
Task DeleteAsync(int id);
}

public class BaseService : IBaseService where T : class
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;

public BaseService(IUnitOfWork unitOfWork, IMapper mapper)
{
    _unitOfWork = unitOfWork;
    _mapper = mapper;
}

public async Task<IEnumerable<TDTO>> GetAllAsync()
{
    var entities = await _unitOfWork.Products.GetAllAsync();
    return _mapper.Map<IEnumerable<TDTO>>(entities);
}

public async Task<TDTO> GetByIdAsync(int id)
{
    var entity = await _unitOfWork.Products.GetByIdAsync(id);
    return _mapper.Map<TDTO>(entity);
}

public async Task<TDTO> AddAsync(TDTO entity)
{
    var model = _mapper.Map<T>(entity);
    await _unitOfWork.Products.AddAsync(model);
    await _unitOfWork.CompleteAsync();
    return entity;
}

public async Task UpdateAsync(int id, TDTO entity)
{
    var model = await _unitOfWork.Products.GetByIdAsync(id);
    _mapper.Map(entity, model);
    await _unitOfWork.CompleteAsync();
}

public async Task DeleteAsync(int id)
{
    var entity = await _unitOfWork.Products.GetByIdAsync(id);
    _unitOfWork.Products.DeleteAsync(entity);
    await _unitOfWork.CompleteAsync();
}
Enter fullscreen mode Exit fullscreen mode

}

Asynchronous Messaging

Asynchronous messaging allows different parts of your application to communicate with each other without blocking each other. This can improve the performance and scalability of your application.

We'll use Azure Service Bus and MassTransit to implement asynchronous messaging in our project.

Azure Service Bus

Azure Service Bus is a fully managed enterprise integration message broker. It can be used to decouple applications and services.

To configure Azure Service Bus, you need to create a Service Bus namespace in the Azure portal and obtain a connection string.

Then, add the following NuGet package to your project:

Install-Package Azure.Messaging.ServiceBus
Enter fullscreen mode Exit fullscreen mode

MassTransit

MassTransit is a free, open-source, lightweight message bus for .NET. It provides a simple and easy-to-use API for sending and receiving messages.

To configure MassTransit, add the following NuGet packages to your project:

Install-Package MassTransit
Install-Package MassTransit.Azure.ServiceBus.Core
Install-Package MassTransit.Newtonsoft
Enter fullscreen mode Exit fullscreen mode

Then, configure MassTransit in your Program.cs file:

builder.Services.AddMassTransit(x =>
{
    x.UsingAzureServiceBus((context, cfg) =>
    {
        cfg.Host("your_service_bus_connection_string");

        cfg.ReceiveEndpoint("my_queue", e =>
        {
            e.Consumer<MyConsumer>();
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

This code configures MassTransit to use Azure Service Bus as the transport and registers a consumer for the my_queue queue.

Defining Messages, Consumers, and Publishers

To define a message, create a simple class:

public class MyMessage
{
    public string Text { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

To define a consumer, create a class that implements the IConsumer<T> interface:

public class MyConsumer : IConsumer<MyMessage>
{
    public async Task Consume(ConsumeContext<MyMessage> context)
    {
        Console.WriteLine($"Received message: {context.Message.Text}");
    }
}
Enter fullscreen mode Exit fullscreen mode

To publish a message, use the IPublishEndpoint interface:

public class MyService
{
    private readonly IPublishEndpoint _publishEndpoint;

    public MyService(IPublishEndpoint publishEndpoint)
    {
        _publishEndpoint = publishEndpoint;
    }

    public async Task SendMessage(string text)
    {
        await _publishEndpoint.Publish(new MyMessage { Text = text });
    }
}

## Filtering and Sorting

Filtering and sorting are essential features for any API that returns a list of data. They allow clients to easily find and order the data they need.

We'll use Sieve to implement filtering and sorting in our project.

### Sieve

Sieve is a library that provides a simple and flexible way to implement filtering, sorting, and pagination in ASP.NET Core APIs.

To configure Sieve, add the following NuGet package to your project:

Enter fullscreen mode Exit fullscreen mode


bash
Install-Package Sieve


Then, register Sieve in your `Program.cs` file:

Enter fullscreen mode Exit fullscreen mode


csharp
builder.Services.AddScoped();


To use Sieve in your API endpoints, inject the `ISieveProcessor` interface and use the `Apply` method to apply filtering and sorting to your data:

Enter fullscreen mode Exit fullscreen mode


csharp
public class ProductsController : ControllerBase
{
private readonly ISieveProcessor _sieveProcessor;
private readonly IProductService _productService;

public ProductsController(ISieveProcessor sieveProcessor, IProductService productService)
{
    _sieveProcessor = sieveProcessor;
    _productService = productService;
}

[HttpGet]
public async Task<IActionResult> Get([FromQuery] SieveModel sieveModel)
{
    var products = await _productService.GetAllAsync();
    var filteredProducts = _sieveProcessor.Apply(sieveModel, products.AsQueryable()).ToList();
    return Ok(filteredProducts);
}
Enter fullscreen mode Exit fullscreen mode

}


This code applies filtering and sorting to the `products` collection based on the values in the `sieveModel` object.

To enable filtering and sorting for specific properties, you can use the `[Sieve]` attribute in your model classes:

Enter fullscreen mode Exit fullscreen mode


csharp
public class Product
{
public int Id { get; set; }
[Sieve(CanFilter = true, CanSort = true)]
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
}

Security

Security is a critical aspect of any API. We'll use JWT (JSON Web Token) authentication to secure our API endpoints.

JWT Authentication

JWT Authentication is a stateless authentication mechanism that uses JSON Web Tokens (JWTs) to verify the identity of users.

To configure JWT authentication, add the following NuGet package to your project:

Install-Package Microsoft.AspNetCore.Authentication.JwtBearer
Enter fullscreen mode Exit fullscreen mode

Then, configure JWT authentication in your Program.cs file:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://your-auth-provider.com";
        options.Audience = "your-api-audience";
    });
Enter fullscreen mode Exit fullscreen mode

This code configures JWT authentication to use the specified authority and audience.

To protect API endpoints, use the [Authorize] attribute:

[Authorize]
[HttpGet]
public async Task<IActionResult> Get()
{
    // Only authenticated users can access this endpoint
    return Ok();
}

## Middleware

Middleware components are executed in the request pipeline and can be used to perform various tasks, such as logging, exception handling, and authentication.

To create custom middleware, you need to create a class that implements the `IMiddleware` interface or follows a specific convention.

### Exception Handling Middleware

Exception handling middleware can be used to catch unhandled exceptions and return appropriate error responses.

Here's an example of exception handling middleware:

Enter fullscreen mode Exit fullscreen mode


csharp
public class ExceptionMiddleware : IMiddleware
{
private readonly ILogger _logger;

public ExceptionMiddleware(ILogger<ExceptionMiddleware> logger)
{
    _logger = logger;
}

public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
    try
    {
        await next(context);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "An unhandled exception occurred.");

        context.Response.StatusCode = 500;
        context.Response.ContentType = "application/json";

        var errorResponse = new
        {
            message = "An unhandled exception occurred.",
            traceId = Guid.NewGuid()
        };

        await context.Response.WriteAsync(JsonConvert.SerializeObject(errorResponse));
    }
}
Enter fullscreen mode Exit fullscreen mode

}


To register the middleware, add the following code to your `Program.cs` file:

Enter fullscreen mode Exit fullscreen mode


csharp
builder.Services.AddTransient();
app.UseMiddleware();


### Logging Middleware

Logging middleware can be used to log request and response information for debugging and monitoring.

Here's an example of logging middleware:

Enter fullscreen mode Exit fullscreen mode


csharp
public class LoggingMiddleware : IMiddleware
{
private readonly ILogger _logger;

public LoggingMiddleware(ILogger<LoggingMiddleware> logger)
{
    _logger = logger;
}

public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
    _logger.LogInformation($"Request: {context.Request.Method} {context.Request.Path}");

    await next(context);

    _logger.LogInformation($"Response: {context.Response.StatusCode}");
}
Enter fullscreen mode Exit fullscreen mode

}


To register the middleware, add the following code to your `Program.cs` file:

Enter fullscreen mode Exit fullscreen mode


csharp
builder.Services.AddTransient();
app.UseMiddleware();

Health Checks

Health checks are used to monitor the health of your application. They can be used to detect problems and automatically restart the application if necessary.

To configure health checks, add the following NuGet package to your project:

Install-Package Microsoft.AspNetCore.Diagnostics.HealthChecks
Enter fullscreen mode Exit fullscreen mode

Then, configure health checks in your Program.cs file:

builder.Services.AddHealthChecks();
app.UseHealthChecks("/health");
Enter fullscreen mode Exit fullscreen mode

This code adds health checks to the application and exposes a health check endpoint at /health.

Dockerization

Docker is a platform for building, shipping, and running applications in containers. Containers provide a consistent and isolated environment for your application, making it easy to deploy and scale.

To dockerize your .NET Core Web API, you need to create a Dockerfile in the project root directory. Here's an example Dockerfile:

FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
WORKDIR /app
EXPOSE 80

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["MyWebApi.csproj", "."]
RUN dotnet restore "./MyWebApi.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "MyWebApi.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "MyWebApi.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyWebApi.dll"]
Enter fullscreen mode Exit fullscreen mode

This Dockerfile defines the steps for building and running your application in a container.

To build the Docker image, run the following command in your terminal:

docker build -t mywebapi .
Enter fullscreen mode Exit fullscreen mode

To run the Docker container, run the following command:

docker run -d -p 8080:80 mywebapi
Enter fullscreen mode Exit fullscreen mode

This will run the Docker container in detached mode and map port 8080 on your host machine to port 80 on the container.

CI/CD with GitHub Workflow

CI/CD (Continuous Integration/Continuous Deployment) is a set of practices that automate the build, test, and deployment process. We'll use GitHub Actions to set up a CI/CD pipeline for our .NET Core Web API.

To create a GitHub Workflow, create a file named .github/workflows/main.yml in your project repository. Here's an example workflow file:

name: CI/CD

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - name: Setup .NET Core
      uses: actions/setup-dotnet@v1
      with:
        dotnet-version: '6.0'
    - name: Install dependencies
      run: dotnet restore
    - name: Build
      run: dotnet build --configuration Release
    - name: Test
      run: dotnet test --configuration Release
    - name: Publish
      run: dotnet publish -c Release -o /tmp/publish
    - name: Deploy to Azure App Service
      uses: azure/webapps-deploy@v2
      with:
        app-name: your-app-name
        slot-name: production
        publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
        package: /tmp/publish
Enter fullscreen mode Exit fullscreen mode

This workflow defines the steps for building, testing, and deploying your application to Azure App Service.

To configure the workflow, you need to:

  • Replace your-app-name with the name of your Azure App Service.
  • Add a secret named AZURE_WEBAPP_PUBLISH_PROFILE to your GitHub repository with the publish profile for your Azure App Service.

Example Controller with CRUD Operations

To demonstrate how to use the concepts we've covered, let's create a simple controller for managing products.

First, create a new controller class named ProductsController.cs:

[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;
    private readonly ISieveProcessor _sieveProcessor;

    public ProductsController(IProductService productService, ISieveProcessor sieveProcessor)
    {
        _productService = productService;
        _sieveProcessor = sieveProcessor;
    }

    [HttpGet]
    public async Task<IActionResult> Get([FromQuery] SieveModel sieveModel)
    {
        var products = await _productService.GetAllAsync();
        var filteredProducts = _sieveProcessor.Apply(sieveModel, products.AsQueryable()).ToList();
        return Ok(filteredProducts);
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
    {
        var product = await _productService.GetByIdAsync(id);
        if (product == null)
        {
            return NotFound();
        }
        return Ok(product);
    }

    [HttpPost]
    public async Task<IActionResult> Post([FromBody] ProductDTO productDto)
    {
        var product = await _productService.AddAsync(productDto);
        return CreatedAtAction(nameof(Get), new { id = product.Id }, product);
    }

    [HttpPut("{id}")]
    public async Task<IActionResult> Put(int id, [FromBody] ProductDTO productDto)
    {
        await _productService.UpdateAsync(id, productDto);
        return NoContent();
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> Delete(int id)
    {
        await _productService.DeleteAsync(id);
        return NoContent();
    }
}
Enter fullscreen mode Exit fullscreen mode

This controller implements the basic CRUD operations for managing products. It uses the IProductService interface to access the business logic and the ISieveProcessor interface to apply filtering and sorting.

To create the IProductService interface and implementation, create a new file named IProductService.cs in the Services/Interface directory:

public interface IProductService : IBaseService<Product, ProductDTO>
{
}
Enter fullscreen mode Exit fullscreen mode

Then, create a new file named ProductService.cs in the Services/Implementation directory:

public class ProductService : BaseService<Product, ProductDTO>, IProductService
{
    public ProductService(IUnitOfWork unitOfWork, IMapper mapper) : base(unitOfWork, mapper)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

This code implements the IProductService interface and inherits the base service class.

Conclusion

Congratulations! You've now learned how to set up a robust .NET Core Web API project with best practices. We've covered everything from project setup to essential concepts, data access, messaging, security, and deployment.

Remember, this is just a starting point. There's always more to learn and explore. I encourage you to experiment with the code, try out different patterns, and dive deeper into the topics that interest you most.

Here are some resources that you may find helpful:

Happy coding!

Comments 1 total

  • José Pablo Ramírez Vargas
    José Pablo Ramírez VargasApr 26, 2025

    Couple of things:

    1. Formatting of the article is all messed up.
    2. The DbContext class of EF fis already an implementation of Unit Of Work, so making another one is unneeded.

    Overall, much more decent than the majority.

    Still, I must clubber down the use of Entity Framework here.

    Why Entity Framework is Bad for ASP.Net

    The DbContext class, as I already mentioned, is a Unit of Work implementation. This contradicts the general contract of RESTful API's.

    In a RESTful API, we don't do multi-entity modifications, and Unit of Work is all about transacting multiple entity modifications. It should therefore be clear that the whole UoW thing that EF brings to the table is wasted code.

    Furthermore, the UoW features collide more often than not with REST implementations. We end up using tricks like AsNoTracking() to bail out of UoW stuff like entity tracking.

    In short, Dapper is 1000x better than EF for a RESTful HTTP server.

Add comment