Build a complete ASP.NET Core API with clean architecture, EF Core, JWT auth, validation, logging, and full test coverage.
This capstone project brings together all ASP.NET Core concepts: clean architecture with separate Domain, Application, Infrastructure, and API layers; EF Core for data access; JWT authentication; validation; logging; and comprehensive testing.
Structure the solution with four clean architecture layers:
1// Solution Structure - Clean Architecture
2ECommerceApi/
3├── src/
4│ ├── ECommerce.Domain/ # Core business logic
5│ │ ├── Entities/
6│ │ │ ├── Product.cs
7│ │ │ ├── Category.cs
8│ │ │ ├── Order.cs
9│ │ │ ├── OrderItem.cs
10│ │ │ └── User.cs
11│ │ ├── ValueObjects/
12│ │ │ ├── Money.cs
13│ │ │ ├── Email.cs
14│ │ │ └── Address.cs
15│ │ ├── Enums/
16│ │ │ └── OrderStatus.cs
17│ │ ├── Exceptions/
18│ │ │ └── DomainException.cs
19│ │ └── Interfaces/
20│ │ ├── IProductRepository.cs
21│ │ └── IOrderRepository.cs
22│ │
23│ ├── ECommerce.Application/ # Use cases & business rules
24│ │ ├── Common/
25│ │ │ ├── Interfaces/
26│ │ │ │ ├── IUnitOfWork.cs
27│ │ │ │ └── ICacheService.cs
28│ │ │ └── Behaviors/
29│ │ │ ├── ValidationBehavior.cs
30│ │ │ └── LoggingBehavior.cs
31│ │ ├── Products/
32│ │ │ ├── Commands/
33│ │ │ │ ├── CreateProduct/
34│ │ │ │ └── UpdateProduct/
35│ │ │ ├── Queries/
36│ │ │ │ ├── GetProducts/
37│ │ │ │ └── GetProductById/
38│ │ │ └── DTOs/
39│ │ │ └── ProductDto.cs
40│ │ └── Orders/
41│ │ ├── Commands/
42│ │ └── Queries/
43│ │
44│ ├── ECommerce.Infrastructure/ # External concerns
45│ │ ├── Persistence/
46│ │ │ ├── AppDbContext.cs
47│ │ │ ├── Configurations/
48│ │ │ └── Repositories/
49│ │ ├── Caching/
50│ │ │ └── RedisCacheService.cs
51│ │ └── DependencyInjection.cs
52│ │
53│ └── ECommerce.Api/ # Presentation layer
54│ ├── Controllers/
55│ │ ├── ProductsController.cs
56│ │ └── OrdersController.cs
57│ ├── Middleware/
58│ │ └── ExceptionHandlingMiddleware.cs
59│ ├── Filters/
60│ └── Program.cs
61│
62├── tests/
63│ ├── ECommerce.Domain.Tests/
64│ ├── ECommerce.Application.Tests/
65│ └── ECommerce.Api.Tests/
66│
67├── docker-compose.yml
68└── README.md
69
70// Program.cs - Application Bootstrap
71using ECommerce.Api.Middleware;
72using ECommerce.Application;
73using ECommerce.Infrastructure;
74
75var builder = WebApplication.CreateBuilder(args);
76
77// Add layers
78builder.Services.AddApplication();
79builder.Services.AddInfrastructure(builder.Configuration);
80
81// Add API services
82builder.Services.AddControllers();
83builder.Services.AddEndpointsApiExplorer();
84builder.Services.AddSwaggerGen(c =>
85{
86 c.SwaggerDoc("v1", new() { Title = "E-Commerce API", Version = "v1" });
87 c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
88 {
89 Type = SecuritySchemeType.Http,
90 Scheme = "bearer",
91 BearerFormat = "JWT"
92 });
93});
94
95// Add health checks
96builder.Services.AddHealthChecks()
97 .AddDbContextCheck<AppDbContext>()
98 .AddRedis(builder.Configuration.GetConnectionString("Redis")!)
99 .AddCheck<ApiHealthCheck>("api");
100
101// Add rate limiting
102builder.Services.AddRateLimiter(options =>
103{
104 options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
105 context => RateLimitPartition.GetFixedWindowLimiter(
106 partitionKey: context.User.Identity?.Name ?? context.Request.Headers.Host.ToString(),
107 factory: _ => new FixedWindowRateLimiterOptions
108 {
109 AutoReplenishment = true,
110 PermitLimit = 100,
111 Window = TimeSpan.FromMinutes(1)
112 }));
113});
114
115var app = builder.Build();
116
117// Configure pipeline
118if (app.Environment.IsDevelopment())
119{
120 app.UseSwagger();
121 app.UseSwaggerUI();
122}
123
124app.UseHttpsRedirection();
125app.UseExceptionHandling();
126app.UseRateLimiter();
127app.UseAuthentication();
128app.UseAuthorization();
129app.MapControllers();
130app.MapHealthChecks("/health");
131
132app.Run();Domain entities carry business rules; application layer use cases orchestrate domain objects:
1// Entities/Product.cs - Rich Domain Model
2namespace ECommerce.Domain.Entities;
3
4public class Product
5{
6 public Guid Id { get; private set; }
7 public string Name { get; private set; } = string.Empty;
8 public string Description { get; private set; } = string.Empty;
9 public Money Price { get; private set; } = null!;
10 public int StockQuantity { get; private set; }
11 public Guid CategoryId { get; private set; }
12 public Category Category { get; private set; } = null!;
13 public bool IsActive { get; private set; }
14 public DateTime CreatedAt { get; private set; }
15 public DateTime? UpdatedAt { get; private set; }
16
17 private Product() { } // EF Core
18
19 public static Product Create(
20 string name,
21 string description,
22 Money price,
23 int stockQuantity,
24 Guid categoryId)
25 {
26 if (string.IsNullOrWhiteSpace(name))
27 throw new DomainException("Product name cannot be empty");
28
29 if (price.Amount <= 0)
30 throw new DomainException("Price must be greater than zero");
31
32 return new Product
33 {
34 Id = Guid.NewGuid(),
35 Name = name,
36 Description = description,
37 Price = price,
38 StockQuantity = stockQuantity,
39 CategoryId = categoryId,
40 IsActive = true,
41 CreatedAt = DateTime.UtcNow
42 };
43 }
44
45 public void UpdateDetails(string name, string description, Money price)
46 {
47 Name = name;
48 Description = description;
49 Price = price;
50 UpdatedAt = DateTime.UtcNow;
51 }
52
53 public void AddStock(int quantity)
54 {
55 if (quantity <= 0)
56 throw new DomainException("Quantity must be positive");
57
58 StockQuantity += quantity;
59 UpdatedAt = DateTime.UtcNow;
60 }
61
62 public void RemoveStock(int quantity)
63 {
64 if (quantity <= 0)
65 throw new DomainException("Quantity must be positive");
66
67 if (StockQuantity < quantity)
68 throw new DomainException("Insufficient stock");
69
70 StockQuantity -= quantity;
71 UpdatedAt = DateTime.UtcNow;
72 }
73
74 public void Deactivate()
75 {
76 IsActive = false;
77 UpdatedAt = DateTime.UtcNow;
78 }
79}
80
81// ValueObjects/Money.cs - Value Object Pattern
82namespace ECommerce.Domain.ValueObjects;
83
84public record Money
85{
86 public decimal Amount { get; }
87 public string Currency { get; }
88
89 private Money(decimal amount, string currency)
90 {
91 Amount = amount;
92 Currency = currency;
93 }
94
95 public static Money Create(decimal amount, string currency = "USD")
96 {
97 if (amount < 0)
98 throw new DomainException("Amount cannot be negative");
99
100 return new Money(Math.Round(amount, 2), currency);
101 }
102
103 public static Money operator +(Money a, Money b)
104 {
105 if (a.Currency != b.Currency)
106 throw new DomainException("Cannot add different currencies");
107
108 return Create(a.Amount + b.Amount, a.Currency);
109 }
110
111 public static Money operator *(Money money, int quantity)
112 {
113 return Create(money.Amount * quantity, money.Currency);
114 }
115}
116
117// Entities/Order.cs - Aggregate Root
118namespace ECommerce.Domain.Entities;
119
120public class Order
121{
122 private readonly List<OrderItem> _items = new();
123
124 public Guid Id { get; private set; }
125 public Guid UserId { get; private set; }
126 public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
127 public OrderStatus Status { get; private set; }
128 public Address ShippingAddress { get; private set; } = null!;
129 public Money TotalAmount { get; private set; } = null!;
130 public DateTime CreatedAt { get; private set; }
131 public DateTime? CompletedAt { get; private set; }
132
133 private Order() { }
134
135 public static Order Create(Guid userId, Address shippingAddress)
136 {
137 return new Order
138 {
139 Id = Guid.NewGuid(),
140 UserId = userId,
141 ShippingAddress = shippingAddress,
142 Status = OrderStatus.Pending,
143 TotalAmount = Money.Create(0),
144 CreatedAt = DateTime.UtcNow
145 };
146 }
147
148 public void AddItem(Product product, int quantity)
149 {
150 if (Status != OrderStatus.Pending)
151 throw new DomainException("Cannot modify a non-pending order");
152
153 var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
154 if (existingItem != null)
155 {
156 existingItem.IncreaseQuantity(quantity);
157 }
158 else
159 {
160 _items.Add(OrderItem.Create(product, quantity));
161 }
162
163 RecalculateTotal();
164 }
165
166 public void RemoveItem(Guid productId)
167 {
168 if (Status != OrderStatus.Pending)
169 throw new DomainException("Cannot modify a non-pending order");
170
171 var item = _items.FirstOrDefault(i => i.ProductId == productId);
172 if (item != null)
173 {
174 _items.Remove(item);
175 RecalculateTotal();
176 }
177 }
178
179 public void Confirm()
180 {
181 if (Status != OrderStatus.Pending)
182 throw new DomainException("Order is not pending");
183
184 if (!_items.Any())
185 throw new DomainException("Cannot confirm empty order");
186
187 Status = OrderStatus.Confirmed;
188 }
189
190 public void Ship()
191 {
192 if (Status != OrderStatus.Confirmed)
193 throw new DomainException("Order must be confirmed before shipping");
194
195 Status = OrderStatus.Shipped;
196 }
197
198 public void Complete()
199 {
200 if (Status != OrderStatus.Shipped)
201 throw new DomainException("Order must be shipped before completing");
202
203 Status = OrderStatus.Completed;
204 CompletedAt = DateTime.UtcNow;
205 }
206
207 public void Cancel()
208 {
209 if (Status is OrderStatus.Shipped or OrderStatus.Completed)
210 throw new DomainException("Cannot cancel shipped or completed order");
211
212 Status = OrderStatus.Cancelled;
213 }
214
215 private void RecalculateTotal()
216 {
217 TotalAmount = _items.Aggregate(
218 Money.Create(0),
219 (total, item) => total + item.TotalPrice);
220 }
221}Infrastructure implements EF Core repositories; API layer wires up controllers and middleware:
1// Controllers/ProductsController.cs
2namespace ECommerce.Api.Controllers;
3
4[ApiController]
5[Route("api/[controller]")]
6[Produces("application/json")]
7public class ProductsController : ControllerBase
8{
9 private readonly IMediator _mediator;
10 private readonly ILogger<ProductsController> _logger;
11
12 public ProductsController(IMediator mediator, ILogger<ProductsController> logger)
13 {
14 _mediator = mediator;
15 _logger = logger;
16 }
17
18 /// <summary>
19 /// Get all products with pagination and filtering
20 /// </summary>
21 [HttpGet]
22 [ProducesResponseType(typeof(PaginatedList<ProductDto>), StatusCodes.Status200OK)]
23 public async Task<ActionResult<PaginatedList<ProductDto>>> GetProducts(
24 [FromQuery] GetProductsQuery query,
25 CancellationToken cancellationToken)
26 {
27 var result = await _mediator.Send(query, cancellationToken);
28 return Ok(result);
29 }
30
31 /// <summary>
32 /// Get a product by ID
33 /// </summary>
34 [HttpGet("{id:guid}")]
35 [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
36 [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
37 public async Task<ActionResult<ProductDto>> GetProduct(
38 Guid id,
39 CancellationToken cancellationToken)
40 {
41 var query = new GetProductByIdQuery(id);
42 var result = await _mediator.Send(query, cancellationToken);
43
44 return result is null ? NotFound() : Ok(result);
45 }
46
47 /// <summary>
48 /// Create a new product
49 /// </summary>
50 [HttpPost]
51 [Authorize(Roles = "Admin")]
52 [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
53 [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
54 public async Task<ActionResult<ProductDto>> CreateProduct(
55 [FromBody] CreateProductCommand command,
56 CancellationToken cancellationToken)
57 {
58 var result = await _mediator.Send(command, cancellationToken);
59 return CreatedAtAction(nameof(GetProduct), new { id = result.Id }, result);
60 }
61
62 /// <summary>
63 /// Update an existing product
64 /// </summary>
65 [HttpPut("{id:guid}")]
66 [Authorize(Roles = "Admin")]
67 [ProducesResponseType(StatusCodes.Status204NoContent)]
68 [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
69 public async Task<IActionResult> UpdateProduct(
70 Guid id,
71 [FromBody] UpdateProductCommand command,
72 CancellationToken cancellationToken)
73 {
74 if (id != command.Id)
75 return BadRequest();
76
77 await _mediator.Send(command, cancellationToken);
78 return NoContent();
79 }
80
81 /// <summary>
82 /// Delete a product
83 /// </summary>
84 [HttpDelete("{id:guid}")]
85 [Authorize(Roles = "Admin")]
86 [ProducesResponseType(StatusCodes.Status204NoContent)]
87 public async Task<IActionResult> DeleteProduct(
88 Guid id,
89 CancellationToken cancellationToken)
90 {
91 await _mediator.Send(new DeleteProductCommand(id), cancellationToken);
92 return NoContent();
93 }
94}
95
96// Application/Products/Queries/GetProducts/GetProductsQuery.cs
97namespace ECommerce.Application.Products.Queries.GetProducts;
98
99public record GetProductsQuery : IRequest<PaginatedList<ProductDto>>
100{
101 public string? SearchTerm { get; init; }
102 public Guid? CategoryId { get; init; }
103 public decimal? MinPrice { get; init; }
104 public decimal? MaxPrice { get; init; }
105 public bool? IsActive { get; init; }
106 public int PageNumber { get; init; } = 1;
107 public int PageSize { get; init; } = 10;
108 public string? SortBy { get; init; }
109 public bool SortDescending { get; init; }
110}
111
112public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, PaginatedList<ProductDto>>
113{
114 private readonly IProductRepository _repository;
115 private readonly ICacheService _cache;
116 private readonly IMapper _mapper;
117
118 public GetProductsQueryHandler(
119 IProductRepository repository,
120 ICacheService cache,
121 IMapper mapper)
122 {
123 _repository = repository;
124 _cache = cache;
125 _mapper = mapper;
126 }
127
128 public async Task<PaginatedList<ProductDto>> Handle(
129 GetProductsQuery request,
130 CancellationToken cancellationToken)
131 {
132 var cacheKey = $"products:{request.GetHashCode()}";
133 var cached = await _cache.GetAsync<PaginatedList<ProductDto>>(cacheKey);
134
135 if (cached is not null)
136 return cached;
137
138 var query = _repository.GetQueryable();
139
140 // Apply filters
141 if (!string.IsNullOrWhiteSpace(request.SearchTerm))
142 {
143 query = query.Where(p =>
144 p.Name.Contains(request.SearchTerm) ||
145 p.Description.Contains(request.SearchTerm));
146 }
147
148 if (request.CategoryId.HasValue)
149 query = query.Where(p => p.CategoryId == request.CategoryId.Value);
150
151 if (request.MinPrice.HasValue)
152 query = query.Where(p => p.Price.Amount >= request.MinPrice.Value);
153
154 if (request.MaxPrice.HasValue)
155 query = query.Where(p => p.Price.Amount <= request.MaxPrice.Value);
156
157 if (request.IsActive.HasValue)
158 query = query.Where(p => p.IsActive == request.IsActive.Value);
159
160 // Apply sorting
161 query = request.SortBy?.ToLower() switch
162 {
163 "name" => request.SortDescending ? query.OrderByDescending(p => p.Name) : query.OrderBy(p => p.Name),
164 "price" => request.SortDescending ? query.OrderByDescending(p => p.Price.Amount) : query.OrderBy(p => p.Price.Amount),
165 "created" => request.SortDescending ? query.OrderByDescending(p => p.CreatedAt) : query.OrderBy(p => p.CreatedAt),
166 _ => query.OrderByDescending(p => p.CreatedAt)
167 };
168
169 var result = await query
170 .Select(p => _mapper.Map<ProductDto>(p))
171 .ToPaginatedListAsync(request.PageNumber, request.PageSize, cancellationToken);
172
173 await _cache.SetAsync(cacheKey, result, TimeSpan.FromMinutes(5));
174
175 return result;
176 }
177}The complete project demonstrates all ASP.NET Core concepts in a production-ready structure:
1// Infrastructure/Persistence/AppDbContext.cs
2namespace ECommerce.Infrastructure.Persistence;
3
4public class AppDbContext : DbContext
5{
6 public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
7
8 public DbSet<Product> Products => Set<Product>();
9 public DbSet<Category> Categories => Set<Category>();
10 public DbSet<Order> Orders => Set<Order>();
11 public DbSet<OrderItem> OrderItems => Set<OrderItem>();
12 public DbSet<User> Users => Set<User>();
13
14 protected override void OnModelCreating(ModelBuilder modelBuilder)
15 {
16 modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
17 }
18
19 public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
20 {
21 foreach (var entry in ChangeTracker.Entries<IHasTimestamps>())
22 {
23 if (entry.State == EntityState.Added)
24 entry.Entity.CreatedAt = DateTime.UtcNow;
25
26 if (entry.State == EntityState.Modified)
27 entry.Entity.UpdatedAt = DateTime.UtcNow;
28 }
29
30 return await base.SaveChangesAsync(cancellationToken);
31 }
32}
33
34// Infrastructure/Persistence/Configurations/ProductConfiguration.cs
35namespace ECommerce.Infrastructure.Persistence.Configurations;
36
37public class ProductConfiguration : IEntityTypeConfiguration<Product>
38{
39 public void Configure(EntityTypeBuilder<Product> builder)
40 {
41 builder.ToTable("Products");
42
43 builder.HasKey(p => p.Id);
44
45 builder.Property(p => p.Name)
46 .HasMaxLength(200)
47 .IsRequired();
48
49 builder.Property(p => p.Description)
50 .HasMaxLength(2000);
51
52 builder.OwnsOne(p => p.Price, priceBuilder =>
53 {
54 priceBuilder.Property(m => m.Amount)
55 .HasColumnName("Price")
56 .HasColumnType("decimal(18,2)")
57 .IsRequired();
58
59 priceBuilder.Property(m => m.Currency)
60 .HasColumnName("Currency")
61 .HasMaxLength(3)
62 .IsRequired();
63 });
64
65 builder.HasOne(p => p.Category)
66 .WithMany(c => c.Products)
67 .HasForeignKey(p => p.CategoryId)
68 .OnDelete(DeleteBehavior.Restrict);
69
70 builder.HasIndex(p => p.Name);
71 builder.HasIndex(p => p.CategoryId);
72 builder.HasIndex(p => p.IsActive);
73 }
74}
75
76// Infrastructure/Caching/RedisCacheService.cs
77namespace ECommerce.Infrastructure.Caching;
78
79public class RedisCacheService : ICacheService
80{
81 private readonly IDistributedCache _cache;
82 private readonly ILogger<RedisCacheService> _logger;
83
84 public RedisCacheService(IDistributedCache cache, ILogger<RedisCacheService> logger)
85 {
86 _cache = cache;
87 _logger = logger;
88 }
89
90 public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
91 {
92 try
93 {
94 var data = await _cache.GetStringAsync(key, cancellationToken);
95 return data is null ? default : JsonSerializer.Deserialize<T>(data);
96 }
97 catch (Exception ex)
98 {
99 _logger.LogWarning(ex, "Failed to get cache key {Key}", key);
100 return default;
101 }
102 }
103
104 public async Task SetAsync<T>(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default)
105 {
106 try
107 {
108 var options = new DistributedCacheEntryOptions
109 {
110 AbsoluteExpirationRelativeToNow = expiration ?? TimeSpan.FromMinutes(10)
111 };
112
113 var data = JsonSerializer.Serialize(value);
114 await _cache.SetStringAsync(key, data, options, cancellationToken);
115 }
116 catch (Exception ex)
117 {
118 _logger.LogWarning(ex, "Failed to set cache key {Key}", key);
119 }
120 }
121
122 public async Task RemoveAsync(string key, CancellationToken cancellationToken = default)
123 {
124 await _cache.RemoveAsync(key, cancellationToken);
125 }
126}
127
128// Infrastructure/DependencyInjection.cs
129namespace ECommerce.Infrastructure;
130
131public static class DependencyInjection
132{
133 public static IServiceCollection AddInfrastructure(
134 this IServiceCollection services,
135 IConfiguration configuration)
136 {
137 // Database
138 services.AddDbContext<AppDbContext>(options =>
139 options.UseNpgsql(
140 configuration.GetConnectionString("DefaultConnection"),
141 b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
142
143 // Caching
144 services.AddStackExchangeRedisCache(options =>
145 {
146 options.Configuration = configuration.GetConnectionString("Redis");
147 options.InstanceName = "ECommerce_";
148 });
149 services.AddSingleton<ICacheService, RedisCacheService>();
150
151 // Repositories
152 services.AddScoped<IProductRepository, ProductRepository>();
153 services.AddScoped<IOrderRepository, OrderRepository>();
154 services.AddScoped<IUnitOfWork, UnitOfWork>();
155
156 // Authentication
157 services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
158 .AddJwtBearer(options =>
159 {
160 options.TokenValidationParameters = new TokenValidationParameters
161 {
162 ValidateIssuer = true,
163 ValidateAudience = true,
164 ValidateLifetime = true,
165 ValidateIssuerSigningKey = true,
166 ValidIssuer = configuration["Jwt:Issuer"],
167 ValidAudience = configuration["Jwt:Audience"],
168 IssuerSigningKey = new SymmetricSecurityKey(
169 Encoding.UTF8.GetBytes(configuration["Jwt:Secret"]!))
170 };
171 });
172
173 services.AddAuthorization();
174
175 return services;
176 }
177}Your progress is saved in this browser, on this device only.