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

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 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 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(); 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; 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; } } 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; } } 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(); CreateMap(); } } Then, register AutoMapper in your Program.cs file: builder.Services.AddAutoMapper(typeof(MappingProfile)); ## Data Access Layer

Apr 22, 2025 - 18:26
 0
Building a Robust .NET Core Web API: A Beginner's Guide

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

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

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

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;

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

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

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

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:


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:


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

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

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

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

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

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

public async Task DeleteAsync(T entity)
{
    _context.Set().Remove(entity);
    await _context.SaveChangesAsync();
}

}


### 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:


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


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


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

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

public IGenericRepository Products { get; private set; }

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

public void Dispose()
{
    _context.Dispose();
}

}


### Base Service

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


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> GetAllAsync()
{
    var entities = await _unitOfWork.Products.GetAllAsync();
    return _mapper.Map>(entities);
}

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

public async Task AddAsync(TDTO entity)
{
    var model = _mapper.Map(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();
}

}

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

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

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

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

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

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

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:


bash
Install-Package Sieve


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


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:


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 Get([FromQuery] SieveModel sieveModel)
{
    var products = await _productService.GetAllAsync();
    var filteredProducts = _sieveProcessor.Apply(sieveModel, products.AsQueryable()).ToList();
    return Ok(filteredProducts);
}

}


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:


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

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

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:


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

public ExceptionMiddleware(ILogger 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));
    }
}

}


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


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:


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

public LoggingMiddleware(ILogger 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}");
}

}


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


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

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

builder.Services.AddHealthChecks();
app.UseHealthChecks("/health");

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"]

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 .

To run the Docker container, run the following command:

docker run -d -p 8080:80 mywebapi

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

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

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>
{
}

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)
    {
    }
}

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!