Complete guide to building a production-ready Web API with ASP.NET Core 8, Entity Framework Core, PostgreSQL, and Docker.
Step 1: Create the Project
# Create new Web API project dotnet new webapi -n ProductAPI --no-https cd ProductAPI # Add required packages dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL dotnet add package Microsoft.EntityFrameworkCore.Design dotnet add package Microsoft.EntityFrameworkCore.Tools dotnet add package Newtonsoft.Json
Step 2: Create Models
// Models/Product.cs
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public bool IsActive { get; set; }
public string Category { get; set; }
}
// Models/Category.cs
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public List Products { get; set; }
}
Step 3: Configure Database Context
// Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
public DbSet<Product> Products { get; set; }
public DbSet<Category> Categories { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Product configuration
modelBuilder.Entity<Product>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
entity.Property(e => e.Description).HasMaxLength(1000);
entity.Property(e => e.Price).HasPrecision(18, 2);
entity.HasIndex(e => e.Name);
entity.HasIndex(e => e.Category);
});
// Category configuration
modelBuilder.Entity<Category>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
entity.HasIndex(e => e.Name).IsUnique();
});
// Seed data
modelBuilder.Entity<Category>().HasData(
new Category { Id = 1, Name = "Electronics", Description = "Electronic devices and accessories" },
new Category { Id = 2, Name = "Books", Description = "Books and publications" },
new Category { Id = 3, Name = "Clothing", Description = "Apparel and fashion items" }
);
}
}
Step 4: Create Repository and Service
// Repositories/IProductRepository.cs
public interface IProductRepository
{
Task<Product> GetByIdAsync(int id);
Task<IEnumerable<Product>> GetAllAsync();
Task<IEnumerable<Product>> GetByCategoryAsync(string category);
Task<Product> AddAsync(Product product);
Task UpdateAsync(Product product);
Task DeleteAsync(int id);
Task<bool> ExistsAsync(int id);
}
// Repositories/ProductRepository.cs
public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context)
{
_context = context;
}
public async Task< Product> GetByIdAsync(int id)
{
return await _context.Products.FindAsync(id);
}
public async Task< IEnumerable<Product>> GetAllAsync()
{
return await _context.Products.ToListAsync();
}
public async Task< IEnumerable<Product>> GetByCategoryAsync(string category)
{
return await _context.Products
.Where(p => p.Category == category && p.IsActive)
.ToListAsync();
}
public async Task< Product> AddAsync(Product product)
{
product.CreatedAt = DateTime.UtcNow;
await _context.Products.AddAsync(product);
await _context.SaveChangesAsync();
return product;
}
public async Task UpdateAsync(Product product)
{
product.UpdatedAt = DateTime.UtcNow;
_context.Entry(product).State = EntityState.Modified;
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var product = await GetByIdAsync(id);
if (product != null)
{
// Soft delete
product.IsActive = false;
product.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
public async Task< bool> ExistsAsync(int id)
{
return await _context.Products.AnyAsync(p => p.Id == id);
}
}
// Services/IProductService.cs
public interface IProductService
{
Task<Product> GetProductAsync(int id);
Task<IEnumerable<Product>> GetProductsAsync();
Task<IEnumerable<Product>> GetProductsByCategoryAsync(string category);
Task<Product> CreateProductAsync(Product product);
Task UpdateProductAsync(Product product);
Task DeleteProductAsync(int id);
}
// Services/ProductService.cs
public class ProductService : IProductService
{
private readonly IProductRepository _repository;
private readonly ILogger<ProductService> _logger;
public ProductService(IProductRepository repository, ILogger< ProductService> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<Product & gt; GetProductAsync(int id)
{
var product = await _repository.GetByIdAsync(id);
if (product == null || !product.IsActive)
throw new KeyNotFoundException($"Product with ID {id} not found");
return product;
}
public async Task< IEnumerable & lt; Product & gt; > GetProductsAsync()
{
var products = await _repository.GetAllAsync();
return products.Where(p => p.IsActive);
}
public async Task< IEnumerable & lt; Product & gt; > GetProductsByCategoryAsync(string category)
{
return await _repository.GetByCategoryAsync(category);
}
public async Task< Product & gt; CreateProductAsync(Product product)
{
// Validate
if (string.IsNullOrEmpty(product.Name))
throw new ArgumentException("Product name is required");
if (product.Price <= 0)
throw new ArgumentException("Price must be greater than zero");
product.IsActive = true;
var created = await _repository.AddAsync(product);
_logger.LogInformation("Created product {ProductId} - {ProductName}", created.Id, created.Name);
return created;
}
public async Task UpdateProductAsync(Product product)
{
var existing = await _repository.GetByIdAsync(product.Id);
if (existing == null)
throw new KeyNotFoundException($"Product with ID {product.Id} not found");
// Update only allowed fields
existing.Name = product.Name;
existing.Description = product.Description;
existing.Price = product.Price;
existing.StockQuantity = product.StockQuantity;
existing.Category = product.Category;
existing.UpdatedAt = DateTime.UtcNow;
await _repository.UpdateAsync(existing);
_logger.LogInformation("Updated product {ProductId}", existing.Id);
}
public async Task DeleteProductAsync(int id)
{
var product = await _repository.GetByIdAsync(id);
if (product == null)
throw new KeyNotFoundException($"Product with ID {id} not found");
await _repository.DeleteAsync(id);
_logger.LogInformation("Deleted product {ProductId}", id);
}
}
</ pre>
Step 5: Create API Controllers
// Controllers/ProductsController.cs
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
private readonly ILogger<ProductsController> _logger;
public ProductsController(IProductService productService, ILogger< ProductsController> logger)
{
_productService = productService;
_logger = logger;
}
[HttpGet]
public async Task< ActionResult & lt; IEnumerable & lt; ProductDto & gt; > > GetProducts()
{
var products = await _productService.GetProductsAsync();
return Ok(products.Select(p => new ProductDto(p)));
}
[HttpGet("{id}")]
public async Task< ActionResult & lt; ProductDto & gt; > GetProduct(int id)
{
try
{
var product = await _productService.GetProductAsync(id);
return Ok(new ProductDto(product));
}
catch (KeyNotFoundException ex)
{
return NotFound(ex.Message);
}
}
[HttpGet("category/{category}")]
public async Task< ActionResult & lt; IEnumerable & lt; ProductDto & gt; > > GetProductsByCategory(string category)
{
var products = await _productService.GetProductsByCategoryAsync(category);
return Ok(products.Select(p => new ProductDto(p)));
}
[HttpPost]
public async Task< ActionResult & lt; ProductDto & gt; > CreateProduct(CreateProductRequest request)
{
try
{
var product = new Product
{
Name = request.Name,
Description = request.Description,
Price = request.Price,
StockQuantity = request.StockQuantity,
Category = request.Category
};
var created = await _productService.CreateProductAsync(product);
return CreatedAtAction(nameof(GetProduct), new { id = created.Id }, new ProductDto(created));
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPut("{id}")]
public async Task< IActionResult & gt; UpdateProduct(int id, UpdateProductRequest request)
{
try
{
var product = new Product
{
Id = id,
Name = request.Name,
Description = request.Description,
Price = request.Price,
StockQuantity = request.StockQuantity,
Category = request.Category
};
await _productService.UpdateProductAsync(product);
return NoContent();
}
catch (KeyNotFoundException ex)
{
return NotFound(ex.Message);
}
}
[HttpDelete("{id}")]
public async Task< IActionResult & gt; DeleteProduct(int id)
{
try
{
await _productService.DeleteProductAsync(id);
return NoContent();
}
catch (KeyNotFoundException ex)
{
return NotFound(ex.Message);
}
}
}
// DTOs/ProductDto.cs
public class ProductDto
{
public ProductDto(Product product)
{
Id = product.Id;
Name = product.Name;
Description = product.Description;
Price = product.Price;
StockQuantity = product.StockQuantity;
Category = product.Category;
CreatedAt = product.CreatedAt;
IsActive = product.IsActive;
}
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
public string Category { get; set; }
public DateTime CreatedAt { get; set; }
public bool IsActive { get; set; }
}
Step 6: Docker Configuration
# Dockerfile FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR / src # Copy csproj and restore COPY ProductAPI.csproj . RUN dotnet restore # Copy source and publish COPY . . RUN dotnet publish -c Release -o /app/publish # Runtime image FROM mcr.microsoft.com/dotnet/aspnet:8.0 WORKDIR / app # Install curl for health checks RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* COPY --from=build /app/publish . EXPOSE 80 EXPOSE 443 ENTRYPOINT ["dotnet", "ProductAPI.dll"]
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: productapi-db
environment:
POSTGRES_USER: productuser
POSTGRES_PASSWORD: productpassword
POSTGRES_DB: productdb
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U productuser"]
interval: 10s
timeout: 5s
retries: 5
api:
build: .
container_name: productapi
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=productdb;Username=productuser;Password=productpassword
ports:
- "5000:80"
depends_on:
postgres:
condition: service_healthy
volumes:
- ./logs:/app/logs
restart: unless-stopped
volumes:
postgres_data:
Step 7: Configure Program.cs
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
options.JsonSerializerOptions.WriteIndented = true;
});
// Database configuration
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString));
// Dependency Injection
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
// CORS
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll",
policy => policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
});
// Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Product API",
Version = "v1",
Description = "Product management API built with ASP.NET Core 8 and PostgreSQL"
});
});
var app = builder.Build();
// Apply migrations and seed database
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.Database.MigrateAsync();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors("AllowAll");
app.UseAuthorization();
app.MapControllers();
app.Run();
Step 8: Run the Application
# Build and run with Docker Compose
docker-compose up --build
# Or run directly with dotnet
dotnet run
# Test the API
curl http://localhost:5000/api/products
# Create a product
curl -X POST http://localhost:5000/api/products \
-H "Content-Type: application/json" \
-d '{
"name": "Laptop",
"description": "High performance laptop",
"price": 999.99,
"stockQuantity": 50,
"category": "Electronics"
}'
# Get product by ID
curl http://localhost:5000/api/products/1
Step 9: Health Check Endpoint
// Controllers/HealthController.cs
[ApiController]
[Route("api/health")]
public class HealthController : ControllerBase
{
private readonly AppDbContext _context;
public HealthController(AppDbContext context)
{
_context = context;
}
[HttpGet]
public async Task<IActionResult> CheckHealth()
{
var canConnect = await _context.Database.CanConnectAsync();
if (!canConnect)
return StatusCode(503, new { status = "unhealthy", database = "unreachable" });
return Ok(new
{
status = "healthy",
database = "connected",
timestamp = DateTime.UtcNow,
version = "1.0.0"
});
}
}
// In Dockerfile, add health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
Step 10: Production Deployment
# Build for production docker build -t productapi:latest . # Run with production settings docker run -d \ --name productapi \ -p 5000:80 \ -e ASPNETCORE_ENVIRONMENT=Production \ -e ConnectionStrings__DefaultConnection="Host=prod-db;Port=5432;Database=productdb;Username=produser;Password=prodpass" \ productapi:latest # Or use Docker Compose with environment variables docker-compose -f docker-compose.prod.yml up -d
