![Full-Stack]()
Previous article: ASP.NET Core Microservices gRPC Message Brokers Architecture Guide (Part- 39 of 40)
Table of Contents
Project Overview & Architecture
Solution Structure & Setup
Domain Layer & Core Models
Infrastructure & Data Layer
Application Layer & CQRS
API Layer & Controllers
AI Integration Services
Blazor Frontend
Real-time Features
Testing Strategy
Deployment & DevOps
Production Readiness
1. Project Overview & Architecture
1.1 Project Vision: SmartCommerce AI
Business Problem: Traditional e-commerce platforms struggle with personalization, real-time inventory, and intelligent customer engagement. Our solution addresses these challenges with AI-powered features.
Key Features
AI-powered product recommendations
Real-time inventory management
Intelligent search with semantic understanding
Personalized pricing and promotions
Multi-vendor marketplace support
Advanced analytics and insights
1.2 System Architecture
1.3 Technology Stack
Backend: ASP.NET Core 8, Entity Framework Core, Clean Architecture
Frontend: Blazor WebAssembly, Blazor Server, MudBlazor
AI/ML: Azure Cognitive Services, ML.NET , OpenAI
Cloud: Azure/AWS, Docker, Kubernetes
Database: Azure SQL, Cosmos DB, Redis
Messaging: Azure Service Bus, SignalR
Monitoring: Application Insights, Serilog
2. Solution Structure & Setup
2.1 Solution Architecture
SmartCommerce/
├── src/
│ ├── SmartCommerce.Web/ # Blazor WebAssembly Frontend
│ ├── SmartCommerce.Admin/ # Blazor Server Admin Panel
│ ├── SmartCommerce.Gateway/ # API Gateway
│ ├── SmartCommerce.Services.Product/ # Product Microservice
│ ├── SmartCommerce.Services.Order/ # Order Microservice
│ ├── SmartCommerce.Services.User/ # User Microservice
│ ├── SmartCommerce.Services.Recommendation/ # AI Recommendation Service
│ ├── SmartCommerce.Services.Search/ # AI Search Service
│ ├── SmartCommerce.Shared/ # Shared Models & Contracts
│ └── SmartCommerce.Infrastructure/ # Cross-cutting Infrastructure
├── tests/
│ ├── SmartCommerce.UnitTests/
│ ├── SmartCommerce.IntegrationTests/
│ └── SmartCommerce.LoadTests/
└── deployments/
├── docker-compose.yml
├── kubernetes/
└── azure-pipelines.yml
2.2 Project Setup & Configuration
xml
<!-- Directory.Build.props -->
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<DebugType>none</DebugType>
<PublishReadyToRun>true</PublishReadyToRun>
<PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>
</Project>
csharp
// Program.cs - Main Web Application
using SmartCommerce.Infrastructure;
using SmartCommerce.Web.Middleware;
var builder = WebApplication.CreateBuilder(args);
// Add configuration sources
builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
.AddEnvironmentVariables()
.AddUserSecrets<Program>()
.AddAzureKeyVault(ConfigurationHelpers.GetKeyVaultEndpoint(builder.Configuration));
// Configure infrastructure
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddApplicationServices();
builder.Services.AddWebServices(builder.Configuration);
// Configure authentication and authorization
builder.Services.AddAuthenticationServices(builder.Configuration);
builder.Services.AddAuthorizationPolicies();
// Add health checks
builder.Services.AddCustomHealthChecks(builder.Configuration);
// Configure HTTP client factory with resilience
builder.Services.AddHttpClientWithResilience(builder.Configuration);
var app = builder.Build();
// Configure middleware pipeline
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
// Custom middleware
app.UseRequestLogging();
app.UseSecurityHeaders();
app.UsePerformanceMonitoring();
// Health check endpoint
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
// API endpoints
app.MapControllers();
app.MapRazorPages();
app.MapFallbackToFile("index.html");
// Application startup tasks
await app.RunStartupTasksAsync();
app.Run();
2.3 Infrastructure Configuration
// Infrastructure/ServiceCollectionExtensions.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using SmartCommerce.Infrastructure.Caching;
using SmartCommerce.Infrastructure.Data;
using SmartCommerce.Infrastructure.Logging;
namespace SmartCommerce.Infrastructure
{
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
// Database Contexts
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"),
sqlOptions =>
{
sqlOptions.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName);
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
}));
// Caching
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = configuration.GetConnectionString("Redis");
options.InstanceName = "SmartCommerce_";
});
services.AddSingleton<IDistributedCache, RedisCache>();
services.AddSingleton<ICacheService, DistributedCacheService>();
// Message Bus
services.AddAzureServiceBus(configuration);
// File Storage
services.AddAzureBlobStorage(configuration);
// Email Services
services.AddEmailServices(configuration);
// Background Services
services.AddHostedService<OrderProcessingService>();
services.AddHostedService<InventorySyncService>();
services.AddHostedService<AIModelTrainingService>();
// External Services
services.AddPaymentServices(configuration);
services.AddShippingServices(configuration);
services.AddAIServices(configuration);
return services;
}
public static IServiceCollection AddAIServices(this IServiceCollection services, IConfiguration configuration)
{
// Azure Cognitive Services
services.AddAzureCognitiveServices(configuration);
// ML.NET Models
services.AddMLServices(configuration);
// Custom AI Services
services.AddScoped<IProductRecommender, AIProductRecommender>();
services.AddScoped<ISearchEnhancer, CognitiveSearchEnhancer>();
services.AddScoped<IPriceOptimizer, MLPriceOptimizer>();
services.AddScoped<ISentimentAnalyzer, AzureSentimentAnalyzer>();
return services;
}
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
// MediatR for CQRS
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(ApplicationLayerEntryPoint).Assembly));
// AutoMapper
services.AddAutoMapper(typeof(ApplicationLayerEntryPoint).Assembly);
// FluentValidation
services.AddValidatorsFromAssembly(typeof(ApplicationLayerEntryPoint).Assembly);
// Domain Services
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IProductService, ProductService>();
services.AddScoped<IUserService, UserService>();
services.AddScoped<IInventoryService, InventoryService>();
return services;
}
}
}
3. Domain Layer & Core Models
3.1 Domain-Driven Design Implementation
// Domain/Common/ValueObjects.cs
using System.Diagnostics;
namespace SmartCommerce.Domain.Common
{
public abstract class ValueObject : IEquatable<ValueObject>
{
protected abstract IEnumerable<object> GetEqualityComponents();
public override bool Equals(object? obj)
{
if (obj is null || obj.GetType() != GetType())
return false;
var valueObject = (ValueObject)obj;
return GetEqualityComponents().SequenceEqual(valueObject.GetEqualityComponents());
}
public override int GetHashCode()
{
return GetEqualityComponents()
.Select(x => x?.GetHashCode() ?? 0)
.Aggregate((x, y) => x ^ y);
}
public bool Equals(ValueObject? other) => Equals((object?)other);
public static bool operator ==(ValueObject left, ValueObject right) => Equals(left, right);
public static bool operator !=(ValueObject left, ValueObject right) => !Equals(left, right);
}
public class Money : ValueObject
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency = "USD")
{
if (amount < 0)
throw new ArgumentException("Money amount cannot be negative");
if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException("Currency cannot be empty");
Amount = amount;
Currency = currency.ToUpperInvariant();
}
public static Money operator +(Money left, Money right)
{
ValidateSameCurrency(left, right);
return new Money(left.Amount + right.Amount, left.Currency);
}
public static Money operator -(Money left, Money right)
{
ValidateSameCurrency(left, right);
return new Money(left.Amount - right.Amount, left.Currency);
}
private static void ValidateSameCurrency(Money left, Money right)
{
if (left.Currency != right.Currency)
throw new InvalidOperationException("Cannot perform operations on different currencies");
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
public override string ToString() => $"{Amount:C} ({Currency})";
}
public class Address : ValueObject
{
public string Street { get; }
public string City { get; }
public string State { get; }
public string Country { get; }
public string ZipCode { get; }
public Address(string street, string city, string state, string country, string zipCode)
{
Street = street ?? throw new ArgumentNullException(nameof(street));
City = city ?? throw new ArgumentNullException(nameof(city));
State = state ?? throw new ArgumentNullException(nameof(state));
Country = country ?? throw new ArgumentNullException(nameof(country));
ZipCode = zipCode ?? throw new ArgumentNullException(nameof(zipCode));
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Street;
yield return City;
yield return State;
yield return Country;
yield return ZipCode;
}
}
}
3.2 Core Domain Entities
// Domain/Entities/Product.cs
using SmartCommerce.Domain.Common;
using SmartCommerce.Domain.Events;
namespace SmartCommerce.Domain.Entities
{
public class Product : AuditableEntity, IAggregateRoot
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public string Description { get; private set; }
public Money Price { get; private set; }
public string Sku { get; private set; }
public int StockQuantity { get; private set; }
public int ReorderLevel { get; private set; }
public bool IsActive { get; private set; }
public bool IsDeleted { get; private set; }
public Guid CategoryId { get; private set; }
public Category Category { get; private set; }
public Guid? VendorId { get; private set; }
public Vendor? Vendor { get; private set; }
// AI-Enhanced Properties
public float AIScore { get; private set; }
public string? AIKeywords { get; private set; }
public DateTime? LastAIAnalysis { get; private set; }
// Navigation Properties
private readonly List<ProductImage> _images = new();
public IReadOnlyCollection<ProductImage> Images => _images.AsReadOnly();
private readonly List<ProductReview> _reviews = new();
public IReadOnlyCollection<ProductReview> Reviews => _reviews.AsReadOnly();
private readonly List<ProductTag> _tags = new();
public IReadOnlyCollection<ProductTag> Tags => _tags.AsReadOnly();
// Private constructor for EF Core
private Product() { }
public Product(string name, string description, Money price, string sku,
Guid categoryId, Guid? vendorId = null, int stockQuantity = 0)
{
Id = Guid.NewGuid();
Name = name ?? throw new ArgumentNullException(nameof(name));
Description = description ?? throw new ArgumentNullException(nameof(description));
Price = price ?? throw new ArgumentNullException(nameof(price));
Sku = sku ?? throw new ArgumentNullException(nameof(sku));
CategoryId = categoryId;
VendorId = vendorId;
StockQuantity = stockQuantity;
IsActive = true;
IsDeleted = false;
ReorderLevel = 10; // Default reorder level
AddDomainEvent(new ProductCreatedEvent(Id, name, sku));
}
public void UpdateDetails(string name, string description, Money price)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Description = description ?? throw new ArgumentNullException(nameof(description));
Price = price ?? throw new ArgumentNullException(nameof(price));
AddDomainEvent(new ProductUpdatedEvent(Id));
}
public void UpdateStock(int quantity)
{
if (quantity < 0)
throw new ArgumentException("Stock quantity cannot be negative");
var oldStock = StockQuantity;
StockQuantity = quantity;
AddDomainEvent(new ProductStockUpdatedEvent(Id, oldStock, quantity));
// Check if we need to reorder
if (quantity <= ReorderLevel)
{
AddDomainEvent(new LowStockEvent(Id, Name, quantity, ReorderLevel));
}
}
public void AddStock(int quantity)
{
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive");
UpdateStock(StockQuantity + quantity);
}
public void RemoveStock(int quantity)
{
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive");
if (quantity > StockQuantity)
throw new InvalidOperationException("Insufficient stock");
UpdateStock(StockQuantity - quantity);
}
public void Deactivate()
{
if (!IsActive)
return;
IsActive = false;
AddDomainEvent(new ProductDeactivatedEvent(Id));
}
public void Activate()
{
if (IsActive)
return;
IsActive = true;
AddDomainEvent(new ProductActivatedEvent(Id));
}
public void MarkAsDeleted()
{
if (IsDeleted)
return;
IsDeleted = true;
IsActive = false;
AddDomainEvent(new ProductDeletedEvent(Id));
}
public void UpdateAIScore(float score, string keywords)
{
AIScore = Math.Clamp(score, 0, 1);
AIKeywords = keywords;
LastAIAnalysis = DateTime.UtcNow;
AddDomainEvent(new ProductAIAnalyzedEvent(Id, score, keywords));
}
public void AddImage(string imageUrl, string altText, bool isPrimary = false)
{
var image = new ProductImage(Id, imageUrl, altText, isPrimary);
_images.Add(image);
// If this is set as primary, ensure no other images are primary
if (isPrimary)
{
foreach (var img in _images.Where(i => i.Id != image.Id && i.IsPrimary))
{
img.SetAsSecondary();
}
}
}
public void AddReview(Guid userId, int rating, string comment, string? title = null)
{
var review = new ProductReview(Id, userId, rating, comment, title);
_reviews.Add(review);
AddDomainEvent(new ProductReviewAddedEvent(Id, userId, rating));
}
public void AddTag(string tagName)
{
if (_tags.Any(t => t.Name.Equals(tagName, StringComparison.OrdinalIgnoreCase)))
return;
var tag = new ProductTag(Id, tagName);
_tags.Add(tag);
}
public decimal CalculateDiscountedPrice(decimal discountPercentage)
{
if (discountPercentage < 0 || discountPercentage > 100)
throw new ArgumentException("Discount percentage must be between 0 and 100");
return Price.Amount * (1 - discountPercentage / 100);
}
public bool IsInStock() => StockQuantity > 0;
public bool NeedsReorder() => StockQuantity <= ReorderLevel;
public int AvailableStock() => Math.Max(0, StockQuantity);
}
public class ProductImage : Entity
{
public Guid Id { get; private set; }
public Guid ProductId { get; private set; }
public string ImageUrl { get; private set; }
public string AltText { get; private set; }
public bool IsPrimary { get; private set; }
public int DisplayOrder { get; private set; }
public DateTime CreatedAt { get; private set; }
public Product Product { get; private set; }
private ProductImage() { }
public ProductImage(Guid productId, string imageUrl, string altText, bool isPrimary = false, int displayOrder = 0)
{
Id = Guid.NewGuid();
ProductId = productId;
ImageUrl = imageUrl ?? throw new ArgumentNullException(nameof(imageUrl));
AltText = altText ?? throw new ArgumentNullException(nameof(altText));
IsPrimary = isPrimary;
DisplayOrder = displayOrder;
CreatedAt = DateTime.UtcNow;
}
public void SetAsPrimary()
{
IsPrimary = true;
}
public void SetAsSecondary()
{
IsPrimary = false;
}
public void UpdateDisplayOrder(int order)
{
DisplayOrder = order;
}
}
}
3.3 Order Management Domain
// Domain/Entities/Order.cs
using SmartCommerce.Domain.Enums;
using SmartCommerce.Domain.Events;
namespace SmartCommerce.Domain.Entities
{
public class Order : AuditableEntity, IAggregateRoot
{
public Guid Id { get; private set; }
public string OrderNumber { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money TotalAmount { get; private set; }
public Money DiscountAmount { get; private set; }
public Money TaxAmount { get; private set; }
public Money ShippingAmount { get; private set; }
public Money FinalAmount { get; private set; }
public string Currency { get; private set; }
// Shipping Information
public Address ShippingAddress { get; private set; }
public Address? BillingAddress { get; private set; }
// Payment Information
public string? PaymentMethod { get; private set; }
public string? PaymentTransactionId { get; private set; }
public DateTime? PaymentDate { get; private set; }
// Shipping Information
public string? ShippingMethod { get; private set; }
public string? TrackingNumber { get; private set; }
public DateTime? ShippedDate { get; private set; }
public DateTime? DeliveredDate { get; private set; }
// Navigation Properties
private readonly List<OrderItem> _orderItems = new();
public IReadOnlyCollection<OrderItem> OrderItems => _orderItems.AsReadOnly();
private readonly List<OrderStatusHistory> _statusHistory = new();
public IReadOnlyCollection<OrderStatusHistory> StatusHistory => _statusHistory.AsReadOnly();
// Private constructor for EF Core
private Order() { }
public Order(Guid customerId, Address shippingAddress, Address? billingAddress = null, string currency = "USD")
{
Id = Guid.NewGuid();
OrderNumber = GenerateOrderNumber();
CustomerId = customerId;
Status = OrderStatus.Pending;
Currency = currency;
ShippingAddress = shippingAddress ?? throw new ArgumentNullException(nameof(shippingAddress));
BillingAddress = billingAddress;
// Initialize amounts
TotalAmount = new Money(0, currency);
DiscountAmount = new Money(0, currency);
TaxAmount = new Money(0, currency);
ShippingAmount = new Money(0, currency);
FinalAmount = new Money(0, currency);
AddStatusHistory(OrderStatus.Pending, "Order created");
AddDomainEvent(new OrderCreatedEvent(Id, OrderNumber, customerId));
}
public void AddItem(Product product, int quantity, Money unitPrice)
{
if (product == null)
throw new ArgumentNullException(nameof(product));
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive");
if (unitPrice.Amount <= 0)
throw new ArgumentException("Unit price must be positive");
// Check if item already exists
var existingItem = _orderItems.FirstOrDefault(item => item.ProductId == product.Id);
if (existingItem != null)
{
existingItem.UpdateQuantity(existingItem.Quantity + quantity);
}
else
{
var orderItem = new OrderItem(Id, product.Id, product.Name, quantity, unitPrice);
_orderItems.Add(orderItem);
}
RecalculateTotals();
AddDomainEvent(new OrderItemAddedEvent(Id, product.Id, quantity));
}
public void RemoveItem(Guid productId)
{
var item = _orderItems.FirstOrDefault(i => i.ProductId == productId);
if (item != null)
{
_orderItems.Remove(item);
RecalculateTotals();
AddDomainEvent(new OrderItemRemovedEvent(Id, productId));
}
}
public void UpdateItemQuantity(Guid productId, int quantity)
{
if (quantity <= 0)
{
RemoveItem(productId);
return;
}
var item = _orderItems.FirstOrDefault(i => i.ProductId == productId);
if (item != null)
{
item.UpdateQuantity(quantity);
RecalculateTotals();
AddDomainEvent(new OrderItemQuantityUpdatedEvent(Id, productId, quantity));
}
}
public void ApplyDiscount(Money discount)
{
if (discount.Amount < 0)
throw new ArgumentException("Discount cannot be negative");
if (discount.Amount > TotalAmount.Amount)
throw new ArgumentException("Discount cannot exceed order total");
DiscountAmount = discount;
RecalculateTotals();
AddDomainEvent(new OrderDiscountAppliedEvent(Id, discount.Amount));
}
public void SetShippingAddress(Address address)
{
ShippingAddress = address ?? throw new ArgumentNullException(nameof(address));
AddDomainEvent(new OrderShippingAddressUpdatedEvent(Id));
}
public void SetBillingAddress(Address address)
{
BillingAddress = address ?? throw new ArgumentNullException(nameof(address));
AddDomainEvent(new OrderBillingAddressUpdatedEvent(Id));
}
public void ProcessPayment(string paymentMethod, string transactionId)
{
if (Status != OrderStatus.Pending)
throw new InvalidOperationException("Order is not in pending status");
PaymentMethod = paymentMethod ?? throw new ArgumentNullException(nameof(paymentMethod));
PaymentTransactionId = transactionId ?? throw new ArgumentNullException(nameof(transactionId));
PaymentDate = DateTime.UtcNow;
UpdateStatus(OrderStatus.Processing, "Payment processed successfully");
AddDomainEvent(new OrderPaymentProcessedEvent(Id, paymentMethod, transactionId));
}
public void MarkAsShipped(string shippingMethod, string trackingNumber)
{
if (Status != OrderStatus.Processing)
throw new InvalidOperationException("Order must be in processing status to ship");
ShippingMethod = shippingMethod ?? throw new ArgumentNullException(nameof(shippingMethod));
TrackingNumber = trackingNumber ?? throw new ArgumentNullException(nameof(trackingNumber));
ShippedDate = DateTime.UtcNow;
UpdateStatus(OrderStatus.Shipped, $"Order shipped via {shippingMethod}");
AddDomainEvent(new OrderShippedEvent(Id, shippingMethod, trackingNumber));
}
public void MarkAsDelivered()
{
if (Status != OrderStatus.Shipped)
throw new InvalidOperationException("Order must be shipped before delivery");
DeliveredDate = DateTime.UtcNow;
UpdateStatus(OrderStatus.Delivered, "Order delivered successfully");
AddDomainEvent(new OrderDeliveredEvent(Id));
}
public void Cancel(string reason)
{
if (Status == OrderStatus.Cancelled)
return;
if (Status == OrderStatus.Delivered)
throw new InvalidOperationException("Cannot cancel a delivered order");
UpdateStatus(OrderStatus.Cancelled, reason ?? "Order cancelled");
AddDomainEvent(new OrderCancelledEvent(Id, reason));
}
private void UpdateStatus(OrderStatus newStatus, string note)
{
var oldStatus = Status;
Status = newStatus;
AddStatusHistory(newStatus, note);
AddDomainEvent(new OrderStatusChangedEvent(Id, oldStatus, newStatus, note));
}
private void AddStatusHistory(OrderStatus status, string note)
{
var history = new OrderStatusHistory(Id, status, note);
_statusHistory.Add(history);
}
private void RecalculateTotals()
{
var total = _orderItems.Sum(item => item.LineTotal.Amount);
TotalAmount = new Money(total, Currency);
// Calculate tax (simplified - in real app, use tax service)
TaxAmount = new Money(total * 0.1m, Currency); // 10% tax
// Calculate final amount
var final = TotalAmount.Amount + TaxAmount.Amount + ShippingAmount.Amount - DiscountAmount.Amount;
FinalAmount = new Money(Math.Max(0, final), Currency);
}
private static string GenerateOrderNumber()
{
return $"ORD-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpper()}";
}
public bool CanBeCancelled() =>
Status == OrderStatus.Pending || Status == OrderStatus.Processing;
public decimal CalculateTax() => TaxAmount.Amount;
public decimal CalculateTotalWithoutTax() => TotalAmount.Amount - TaxAmount.Amount;
}
public class OrderItem : Entity
{
public Guid Id { get; private set; }
public Guid OrderId { get; private set; }
public Guid ProductId { get; private set; }
public string ProductName { get; private set; }
public int Quantity { get; private set; }
public Money UnitPrice { get; private set; }
public Money LineTotal { get; private set; }
public Order Order { get; private set; }
public Product Product { get; private set; }
private OrderItem() { }
public OrderItem(Guid orderId, Guid productId, string productName, int quantity, Money unitPrice)
{
Id = Guid.NewGuid();
OrderId = orderId;
ProductId = productId;
ProductName = productName ?? throw new ArgumentNullException(nameof(productName));
Quantity = quantity;
UnitPrice = unitPrice;
LineTotal = new Money(unitPrice.Amount * quantity, unitPrice.Currency);
}
public void UpdateQuantity(int quantity)
{
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive");
Quantity = quantity;
LineTotal = new Money(UnitPrice.Amount * quantity, UnitPrice.Currency);
}
public void UpdateUnitPrice(Money unitPrice)
{
UnitPrice = unitPrice ?? throw new ArgumentNullException(nameof(unitPrice));
LineTotal = new Money(UnitPrice.Amount * Quantity, UnitPrice.Currency);
}
}
}
4. Infrastructure & Data Layer
4.1 Entity Framework Configuration
// Infrastructure/Data/Configurations/ProductConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SmartCommerce.Domain.Entities;
namespace SmartCommerce.Infrastructure.Data.Configurations
{
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable("Products");
builder.HasKey(p => p.Id);
builder.Property(p => p.Id)
.ValueGeneratedNever()
.IsRequired();
builder.Property(p => p.Name)
.HasMaxLength(200)
.IsRequired();
builder.Property(p => p.Description)
.HasMaxLength(2000)
.IsRequired();
builder.Property(p => p.Sku)
.HasMaxLength(100)
.IsRequired();
builder.Property(p => p.StockQuantity)
.IsRequired();
builder.Property(p => p.ReorderLevel)
.IsRequired();
builder.Property(p => p.IsActive)
.IsRequired();
builder.Property(p => p.IsDeleted)
.IsRequired();
builder.Property(p => p.AIScore)
.HasColumnType("decimal(3,2)");
builder.Property(p => p.AIKeywords)
.HasMaxLength(500);
// Value Objects as owned types
builder.OwnsOne(p => p.Price, money =>
{
money.Property(m => m.Amount)
.HasColumnType("decimal(18,2)")
.HasColumnName("PriceAmount");
money.Property(m => m.Currency)
.HasMaxLength(3)
.HasColumnName("PriceCurrency");
});
// Indexes
builder.HasIndex(p => p.Sku)
.IsUnique()
.HasDatabaseName("IX_Products_Sku");
builder.HasIndex(p => p.Name)
.HasDatabaseName("IX_Products_Name");
builder.HasIndex(p => p.CategoryId)
.HasDatabaseName("IX_Products_CategoryId");
builder.HasIndex(p => p.IsActive)
.HasDatabaseName("IX_Products_IsActive");
builder.HasIndex(p => new { p.IsActive, p.IsDeleted })
.HasDatabaseName("IX_Products_ActiveNotDeleted");
// Query filter for soft delete
builder.HasQueryFilter(p => !p.IsDeleted);
// Relationships
builder.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(p => p.Vendor)
.WithMany(v => v.Products)
.HasForeignKey(p => p.VendorId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasMany(p => p.Images)
.WithOne(pi => pi.Product)
.HasForeignKey(pi => pi.ProductId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(p => p.Reviews)
.WithOne(pr => pr.Product)
.HasForeignKey(pr => pr.ProductId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(p => p.Tags)
.WithOne(pt => pt.Product)
.HasForeignKey(pt => pt.ProductId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders");
builder.HasKey(o => o.Id);
builder.Property(o => o.Id)
.ValueGeneratedNever()
.IsRequired();
builder.Property(o => o.OrderNumber)
.HasMaxLength(50)
.IsRequired();
builder.Property(o => o.CustomerId)
.IsRequired();
builder.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(20)
.IsRequired();
builder.Property(o => o.Currency)
.HasMaxLength(3)
.IsRequired();
builder.Property(o => o.PaymentMethod)
.HasMaxLength(50);
builder.Property(o => o.PaymentTransactionId)
.HasMaxLength(100);
builder.Property(o => o.ShippingMethod)
.HasMaxLength(50);
builder.Property(o => o.TrackingNumber)
.HasMaxLength(100);
// Owned types for Value Objects
builder.OwnsOne(o => o.ShippingAddress, address =>
{
address.Property(a => a.Street).HasMaxLength(200).HasColumnName("ShippingStreet");
address.Property(a => a.City).HasMaxLength(100).HasColumnName("ShippingCity");
address.Property(a => a.State).HasMaxLength(100).HasColumnName("ShippingState");
address.Property(a => a.Country).HasMaxLength(100).HasColumnName("ShippingCountry");
address.Property(a => a.ZipCode).HasMaxLength(20).HasColumnName("ShippingZipCode");
});
builder.OwnsOne(o => o.BillingAddress, address =>
{
address.Property(a => a.Street).HasMaxLength(200).HasColumnName("BillingStreet");
address.Property(a => a.City).HasMaxLength(100).HasColumnName("BillingCity");
address.Property(a => a.State).HasMaxLength(100).HasColumnName("BillingState");
address.Property(a => a.Country).HasMaxLength(100).HasColumnName("BillingCountry");
address.Property(a => a.ZipCode).HasMaxLength(20).HasColumnName("BillingZipCode");
});
// Owned types for Money Value Objects
builder.OwnsOne(o => o.TotalAmount, money =>
{
money.Property(m => m.Amount).HasColumnType("decimal(18,2)").HasColumnName("TotalAmount");
money.Property(m => m.Currency).HasMaxLength(3).HasColumnName("TotalCurrency");
});
builder.OwnsOne(o => o.DiscountAmount, money =>
{
money.Property(m => m.Amount).HasColumnType("decimal(18,2)").HasColumnName("DiscountAmount");
money.Property(m => m.Currency).HasMaxLength(3).HasColumnName("DiscountCurrency");
});
builder.OwnsOne(o => o.TaxAmount, money =>
{
money.Property(m => m.Amount).HasColumnType("decimal(18,2)").HasColumnName("TaxAmount");
money.Property(m => m.Currency).HasMaxLength(3).HasColumnName("TaxCurrency");
});
builder.OwnsOne(o => o.ShippingAmount, money =>
{
money.Property(m => m.Amount).HasColumnType("decimal(18,2)").HasColumnName("ShippingAmount");
money.Property(m => m.Currency).HasMaxLength(3).HasColumnName("ShippingCurrency");
});
builder.OwnsOne(o => o.FinalAmount, money =>
{
money.Property(m => m.Amount).HasColumnType("decimal(18,2)").HasColumnName("FinalAmount");
money.Property(m => m.Currency).HasMaxLength(3).HasColumnName("FinalCurrency");
});
// Indexes
builder.HasIndex(o => o.OrderNumber)
.IsUnique()
.HasDatabaseName("IX_Orders_OrderNumber");
builder.HasIndex(o => o.CustomerId)
.HasDatabaseName("IX_Orders_CustomerId");
builder.HasIndex(o => o.Status)
.HasDatabaseName("IX_Orders_Status");
builder.HasIndex(o => o.Created)
.HasDatabaseName("IX_Orders_Created");
// Relationships
builder.HasMany(o => o.OrderItems)
.WithOne(oi => oi.Order)
.HasForeignKey(oi => oi.OrderId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(o => o.StatusHistory)
.WithOne(osh => osh.Order)
.HasForeignKey(osh => osh.OrderId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}
4.2 Repository Pattern Implementation
// Infrastructure/Data/Repositories/ProductRepository.cs
using Microsoft.EntityFrameworkCore;
using SmartCommerce.Application.Common.Interfaces;
using SmartCommerce.Domain.Entities;
using SmartCommerce.Domain.Specifications;
namespace SmartCommerce.Infrastructure.Data.Repositories
{
public class ProductRepository : IProductRepository
{
private readonly ApplicationDbContext _context;
public ProductRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<Product?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Products
.Include(p => p.Category)
.Include(p => p.Vendor)
.Include(p => p.Images)
.Include(p => p.Tags)
.Include(p => p.Reviews)
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Product>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.Products
.Include(p => p.Category)
.Include(p => p.Images.Where(pi => pi.IsPrimary))
.Where(p => p.IsActive && !p.IsDeleted)
.OrderBy(p => p.Name)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<Product>> GetBySpecificationAsync(ISpecification<Product> specification, CancellationToken cancellationToken = default)
{
return await ApplySpecification(specification).ToListAsync(cancellationToken);
}
public async Task<Product?> GetBySpecificationAsync(ISingleResultSpecification<Product> specification, CancellationToken cancellationToken = default)
{
return await ApplySpecification(specification).FirstOrDefaultAsync(cancellationToken);
}
public async Task<int> CountAsync(ISpecification<Product> specification, CancellationToken cancellationToken = default)
{
return await ApplySpecification(specification, true).CountAsync(cancellationToken);
}
public async Task<bool> ExistsAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Products.AnyAsync(p => p.Id == id && !p.IsDeleted, cancellationToken);
}
public async Task<Product> AddAsync(Product entity, CancellationToken cancellationToken = default)
{
await _context.Products.AddAsync(entity, cancellationToken);
return entity;
}
public void Update(Product entity)
{
_context.Products.Update(entity);
}
public void Delete(Product entity)
{
entity.MarkAsDeleted();
_context.Products.Update(entity);
}
public async Task<IReadOnlyList<Product>> GetFeaturedProductsAsync(int count, CancellationToken cancellationToken = default)
{
return await _context.Products
.Include(p => p.Category)
.Include(p => p.Images.Where(pi => pi.IsPrimary))
.Where(p => p.IsActive && !p.IsDeleted && p.AIScore > 0.7f)
.OrderByDescending(p => p.AIScore)
.ThenByDescending(p => p.Created)
.Take(count)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<Product>> SearchProductsAsync(string searchTerm, int page = 1, int pageSize = 20, CancellationToken cancellationToken = default)
{
var query = _context.Products
.Include(p => p.Category)
.Include(p => p.Images.Where(pi => pi.IsPrimary))
.Where(p => p.IsActive && !p.IsDeleted);
if (!string.IsNullOrWhiteSpace(searchTerm))
{
searchTerm = searchTerm.Trim().ToLower();
query = query.Where(p =>
p.Name.ToLower().Contains(searchTerm) ||
p.Description.ToLower().Contains(searchTerm) ||
p.Sku.ToLower().Contains(searchTerm) ||
p.AIKeywords != null && p.AIKeywords.ToLower().Contains(searchTerm) ||
p.Tags.Any(t => t.Name.ToLower().Contains(searchTerm)));
}
return await query
.OrderByDescending(p => p.AIScore)
.ThenBy(p => p.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<Product>> GetProductsByCategoryAsync(Guid categoryId, int page = 1, int pageSize = 20, CancellationToken cancellationToken = default)
{
return await _context.Products
.Include(p => p.Category)
.Include(p => p.Images.Where(pi => pi.IsPrimary))
.Where(p => p.IsActive && !p.IsDeleted && p.CategoryId == categoryId)
.OrderByDescending(p => p.AIScore)
.ThenBy(p => p.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
}
public async Task UpdateAIScoresAsync(Dictionary<Guid, float> productScores, CancellationToken cancellationToken = default)
{
var productIds = productScores.Keys.ToList();
var products = await _context.Products
.Where(p => productIds.Contains(p.Id))
.ToListAsync(cancellationToken);
foreach (var product in products)
{
if (productScores.TryGetValue(product.Id, out var score))
{
product.UpdateAIScore(score, string.Empty); // Keywords would be set separately
}
}
_context.Products.UpdateRange(products);
}
private IQueryable<Product> ApplySpecification(ISpecification<Product> specification, bool forCount = false)
{
var query = _context.Products.AsQueryable();
// Include related entities if not counting
if (!forCount)
{
query = query
.Include(p => p.Category)
.Include(p => p.Images.Where(pi => pi.IsPrimary))
.Include(p => p.Tags);
}
// Apply specification criteria
if (specification.Criteria != null)
{
query = query.Where(specification.Criteria);
}
// Apply ordering if not counting
if (!forCount && specification.OrderBy != null)
{
query = specification.OrderBy(query);
}
else if (!forCount && specification.OrderByDescending != null)
{
query = specification.OrderByDescending(query);
}
// Apply paging if not counting
if (!forCount && specification.IsPagingEnabled)
{
query = query.Skip(specification.Skip)
.Take(specification.Take);
}
return query;
}
}
}
5. Application Layer & CQRS
5.1 CQRS Implementation with MediatR
// Application/Features/Products/Queries/GetProductDetail/GetProductDetailQuery.cs
using AutoMapper;
using AutoMapper.QueryableExtensions;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SmartCommerce.Application.Common.Interfaces;
using SmartCommerce.Application.Common.Models;
namespace SmartCommerce.Application.Features.Products.Queries.GetProductDetail
{
public record GetProductDetailQuery : IRequest<Result<ProductDetailDto>>
{
public Guid Id { get; init; }
}
public class GetProductDetailQueryHandler : IRequestHandler<GetProductDetailQuery, Result<ProductDetailDto>>
{
private readonly IApplicationDbContext _context;
private readonly IMapper _mapper;
private readonly ICurrentUserService _currentUserService;
public GetProductDetailQueryHandler(IApplicationDbContext context, IMapper mapper, ICurrentUserService currentUserService)
{
_context = context;
_mapper = mapper;
_currentUserService = currentUserService;
}
public async Task<Result<ProductDetailDto>> Handle(GetProductDetailQuery request, CancellationToken cancellationToken)
{
var product = await _context.Products
.Include(p => p.Category)
.Include(p => p.Vendor)
.Include(p => p.Images)
.Include(p => p.Tags)
.Include(p => p.Reviews)
.ThenInclude(r => r.User)
.Where(p => p.IsActive && !p.IsDeleted)
.ProjectTo<ProductDetailDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync(p => p.Id == request.Id, cancellationToken);
if (product == null)
{
return Result<ProductDetailDto>.Failure($"Product with ID {request.Id} not found.");
}
// Track product view for recommendations
if (_currentUserService.UserId.HasValue)
{
// Fire and forget - don't await to avoid blocking the response
_ = Task.Run(async () =>
{
try
{
await TrackProductViewAsync(request.Id, _currentUserService.UserId.Value);
}
catch (Exception ex)
{
// Log but don't throw - this shouldn't affect the main request
// In production, use proper logging
Console.WriteLine($"Failed to track product view: {ex.Message}");
}
});
}
return Result<ProductDetailDto>.Success(product);
}
private async Task TrackProductViewAsync(Guid productId, Guid userId)
{
// Implementation would track product views for recommendation engine
// This could be done via a message bus or direct database call
var viewEvent = new ProductViewedEvent
{
ProductId = productId,
UserId = userId,
ViewedAt = DateTime.UtcNow
};
// Publish event or save to database
await Task.CompletedTask;
}
}
public class ProductDetailDto
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public string Currency { get; set; } = "USD";
public string Sku { get; set; } = string.Empty;
public int StockQuantity { get; set; }
public bool IsInStock => StockQuantity > 0;
public float AIScore { get; set; }
public string? AIKeywords { get; set; }
public Guid CategoryId { get; set; }
public string CategoryName { get; set; } = string.Empty;
public Guid? VendorId { get; set; }
public string? VendorName { get; set; }
public List<ProductImageDto> Images { get; set; } = new();
public List<ProductTagDto> Tags { get; set; } = new();
public List<ProductReviewDto> Reviews { get; set; } = new();
public decimal AverageRating => Reviews.Any() ? Reviews.Average(r => r.Rating) : 0;
public int ReviewCount => Reviews.Count;
public DateTime Created { get; set; }
public DateTime? LastModified { get; set; }
}
public class ProductImageDto
{
public Guid Id { get; set; }
public string ImageUrl { get; set; } = string.Empty;
public string AltText { get; set; } = string.Empty;
public bool IsPrimary { get; set; }
public int DisplayOrder { get; set; }
}
public class ProductReviewDto
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public int Rating { get; set; }
public string? Title { get; set; }
public string Comment { get; set; } = string.Empty;
public DateTime Created { get; set; }
}
}
5.2 Command Implementation
// Application/Features/Products/Commands/CreateProduct/CreateProductCommand.cs
using AutoMapper;
using FluentValidation;
using MediatR;
using SmartCommerce.Application.Common.Interfaces;
using SmartCommerce.Application.Common.Models;
using SmartCommerce.Domain.Entities;
namespace SmartCommerce.Application.Features.Products.Commands.CreateProduct
{
public record CreateProductCommand : IRequest<Result<Guid>>
{
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public decimal Price { get; init; }
public string Currency { get; init; } = "USD";
public string Sku { get; init; } = string.Empty;
public int StockQuantity { get; init; }
public Guid CategoryId { get; init; }
public Guid? VendorId { get; init; }
public List<ProductImageCommand> Images { get; init; } = new();
public List<string> Tags { get; init; } = new();
}
public class ProductImageCommand
{
public string ImageUrl { get; init; } = string.Empty;
public string AltText { get; init; } = string.Empty;
public bool IsPrimary { get; init; }
}
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
private readonly IApplicationDbContext _context;
public CreateProductCommandValidator(IApplicationDbContext context)
{
_context = context;
RuleFor(v => v.Name)
.NotEmpty().WithMessage("Name is required.")
.MaximumLength(200).WithMessage("Name must not exceed 200 characters.");
RuleFor(v => v.Description)
.NotEmpty().WithMessage("Description is required.")
.MaximumLength(2000).WithMessage("Description must not exceed 2000 characters.");
RuleFor(v => v.Price)
.GreaterThan(0).WithMessage("Price must be greater than 0.");
RuleFor(v => v.Sku)
.NotEmpty().WithMessage("SKU is required.")
.MaximumLength(100).WithMessage("SKU must not exceed 100 characters.")
.MustAsync(BeUniqueSku).WithMessage("The specified SKU already exists.");
RuleFor(v => v.StockQuantity)
.GreaterThanOrEqualTo(0).WithMessage("Stock quantity cannot be negative.");
RuleFor(v => v.CategoryId)
.NotEmpty().WithMessage("Category is required.")
.MustAsync(CategoryExists).WithMessage("The specified category does not exist.");
RuleFor(v => v.Images)
.Must(HaveAtLeastOnePrimaryImage)
.When(v => v.Images.Any())
.WithMessage("At least one image must be marked as primary.");
RuleForEach(v => v.Tags)
.NotEmpty().WithMessage("Tag cannot be empty.")
.MaximumLength(50).WithMessage("Tag must not exceed 50 characters.");
}
private async Task<bool> BeUniqueSku(string sku, CancellationToken cancellationToken)
{
return await _context.Products
.AllAsync(p => p.Sku != sku, cancellationToken);
}
private async Task<bool> CategoryExists(Guid categoryId, CancellationToken cancellationToken)
{
return await _context.Categories
.AnyAsync(c => c.Id == categoryId, cancellationToken);
}
private bool HaveAtLeastOnePrimaryImage(List<ProductImageCommand> images)
{
return images.Any(i => i.IsPrimary);
}
}
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, Result<Guid>>
{
private readonly IApplicationDbContext _context;
private readonly IMapper _mapper;
private readonly IAIService _aiService;
public CreateProductCommandHandler(IApplicationDbContext context, IMapper mapper, IAIService aiService)
{
_context = context;
_mapper = mapper;
_aiService = aiService;
}
public async Task<Result<Guid>> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
try
{
// Create product
var price = new Money(request.Price, request.Currency);
var product = new Product(
request.Name,
request.Description,
price,
request.Sku,
request.CategoryId,
request.VendorId,
request.StockQuantity);
// Add images
foreach (var imageCommand in request.Images)
{
product.AddImage(imageCommand.ImageUrl, imageCommand.AltText, imageCommand.IsPrimary);
}
// Add tags
foreach (var tagName in request.Tags)
{
product.AddTag(tagName);
}
// AI Analysis (fire and forget)
_ = Task.Run(async () =>
{
try
{
await AnalyzeProductWithAIAsync(product);
}
catch (Exception ex)
{
// Log AI analysis failure but don't fail the product creation
// In production, use proper logging
Console.WriteLine($"AI analysis failed for product {product.Id}: {ex.Message}");
}
}, cancellationToken);
// Save product
await _context.Products.AddAsync(product, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return Result<Guid>.Success(product.Id);
}
catch (Exception ex)
{
return Result<Guid>.Failure($"Failed to create product: {ex.Message}");
}
}
private async Task AnalyzeProductWithAIAsync(Product product)
{
// Analyze product with AI services
var analysisResult = await _aiService.AnalyzeProductAsync(
product.Name,
product.Description,
product.Tags.Select(t => t.Name).ToList());
product.UpdateAIScore(analysisResult.Score, analysisResult.Keywords);
_context.Products.Update(product);
await _context.SaveChangesAsync();
}
}
}
6. API Layer & Controllers
6.1 API Controllers with Best Practices
// Web/Controllers/ProductsController.cs
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SmartCommerce.Application.Features.Products.Commands.CreateProduct;
using SmartCommerce.Application.Features.Products.Commands.DeleteProduct;
using SmartCommerce.Application.Features.Products.Commands.UpdateProduct;
using SmartCommerce.Application.Features.Products.Queries.GetProductDetail;
using SmartCommerce.Application.Features.Products.Queries.GetProducts;
using SmartCommerce.Web.Filters;
namespace SmartCommerce.Web.Controllers
{
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
[ServiceFilter(typeof(ApiExceptionFilter))]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<ProductsController> _logger;
public ProductsController(IMediator mediator, ILogger<ProductsController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// Get paginated list of products
/// </summary>
/// <param name="query">Query parameters for filtering and pagination</param>
/// <returns>Paginated list of products</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PaginatedList<ProductDto>>> GetProducts([FromQuery] GetProductsQuery query)
{
_logger.LogInformation("Getting products with query: {@Query}", query);
var result = await _mediator.Send(query);
if (result.Succeeded)
{
// Add pagination headers
Response.Headers.Append("X-Pagination", result.Data.ToJson());
return Ok(result.Data.Items);
}
return BadRequest(result.Errors);
}
/// <summary>
/// Get product by ID
/// </summary>
/// <param name="id">Product ID</param>
/// <returns>Product details</returns>
[HttpGet("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDetailDto>> GetProduct(Guid id)
{
_logger.LogInformation("Getting product with ID: {ProductId}", id);
var query = new GetProductDetailQuery { Id = id };
var result = await _mediator.Send(query);
if (result.Succeeded)
{
return Ok(result.Data);
}
return NotFound(result.Errors);
}
/// <summary>
/// Create a new product
/// </summary>
/// <param name="command">Product creation data</param>
/// <returns>Created product ID</returns>
[HttpPost]
[Authorize(Roles = "Admin,ProductManager")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<Guid>> CreateProduct(CreateProductCommand command)
{
_logger.LogInformation("Creating new product: {@Product}", command);
var result = await _mediator.Send(command);
if (result.Succeeded)
{
return CreatedAtAction(nameof(GetProduct), new { id = result.Data }, result.Data);
}
return BadRequest(result.Errors);
}
/// <summary>
/// Update an existing product
/// </summary>
/// <param name="id">Product ID</param>
/// <param name="command">Product update data</param>
/// <returns>No content</returns>
[HttpPut("{id:guid}")]
[Authorize(Roles = "Admin,ProductManager")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> UpdateProduct(Guid id, UpdateProductCommand command)
{
if (id != command.Id)
{
return BadRequest("ID in route does not match ID in body");
}
_logger.LogInformation("Updating product {ProductId} with data: {@Product}", id, command);
var result = await _mediator.Send(command);
if (result.Succeeded)
{
return NoContent();
}
if (result.Errors.Any(e => e.Contains("not found", StringComparison.OrdinalIgnoreCase)))
{
return NotFound(result.Errors);
}
return BadRequest(result.Errors);
}
/// <summary>
/// Delete a product
/// </summary>
/// <param name="id">Product ID</param>
/// <returns>No content</returns>
[HttpDelete("{id:guid}")]
[Authorize(Roles = "Admin,ProductManager")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> DeleteProduct(Guid id)
{
_logger.LogInformation("Deleting product with ID: {ProductId}", id);
var command = new DeleteProductCommand { Id = id };
var result = await _mediator.Send(command);
if (result.Succeeded)
{
return NoContent();
}
return NotFound(result.Errors);
}
/// <summary>
/// Search products
/// </summary>
/// <param name="searchTerm">Search term</param>
/// <param name="page">Page number</param>
/// <param name="pageSize">Page size</param>
/// <returns>Search results</returns>
[HttpGet("search")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<List<ProductDto>>> SearchProducts(
[FromQuery] string searchTerm,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
if (string.IsNullOrWhiteSpace(searchTerm) || searchTerm.Length < 2)
{
return BadRequest("Search term must be at least 2 characters long");
}
_logger.LogInformation("Searching products with term: {SearchTerm}", searchTerm);
var query = new SearchProductsQuery
{
SearchTerm = searchTerm,
Page = page,
PageSize = pageSize
};
var result = await _mediator.Send(query);
if (result.Succeeded)
{
return Ok(result.Data);
}
return BadRequest(result.Errors);
}
/// <summary>
/// Get featured products
/// </summary>
/// <param name="count">Number of featured products to return</param>
/// <returns>List of featured products</returns>
[HttpGet("featured")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<List<ProductDto>>> GetFeaturedProducts([FromQuery] int count = 10)
{
_logger.LogInformation("Getting {Count} featured products", count);
var query = new GetFeaturedProductsQuery { Count = count };
var result = await _mediator.Send(query);
if (result.Succeeded)
{
return Ok(result.Data);
}
return BadRequest(result.Errors);
}
}
}
6.2 API Versioning and Documentation
// Web/Configuration/SwaggerConfiguration.cs
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.Filters;
namespace SmartCommerce.Web.Configuration
{
public static class SwaggerConfiguration
{
public static IServiceCollection AddSwaggerConfiguration(this IServiceCollection services, IConfiguration configuration)
{
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "SmartCommerce API",
Version = "v1",
Description = "AI-Powered E-Commerce Platform API",
Contact = new OpenApiContact
{
Name = "SmartCommerce Team",
Email = "[email protected]",
Url = new Uri("https://smartcommerce.com")
},
License = new OpenApiLicense
{
Name = "MIT License",
Url = new Uri("https://opensource.org/licenses/MIT")
}
});
// Add JWT Authentication
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer"
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
},
Scheme = "oauth2",
Name = "Bearer",
In = ParameterLocation.Header
},
new List<string>()
}
});
// Add XML comments
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
options.IncludeXmlComments(xmlPath);
// Operation filters
options.OperationFilter<AppendAuthorizeToSummaryOperationFilter>();
options.OperationFilter<SecurityRequirementsOperationFilter>();
// Schema filters
options.SchemaFilter<EnumSchemaFilter>();
// Support for polymorphic types
options.UseAllOfForInheritance();
options.UseOneOfForPolymorphism();
// Custom filters
options.OperationFilter<CorrelationIdOperationFilter>();
});
services.AddSwaggerGenNewtonsoftSupport();
return services;
}
public static IApplicationBuilder UseSwaggerConfiguration(this IApplicationBuilder app)
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "SmartCommerce API V1");
options.RoutePrefix = "api-docs";
options.DocumentTitle = "SmartCommerce API Documentation";
options.EnablePersistAuthorization();
options.EnableDeepLinking();
options.DisplayOperationId();
options.DisplayRequestDuration();
options.DefaultModelsExpandDepth(-1); // Hide schemas by default
// Custom CSS
options.InjectStylesheet("/swagger-ui/custom.css");
});
return app;
}
}
}
7. AI Integration Services
7.1 AI-Powered Recommendation Engine
csharp
// Infrastructure/AI/RecommendationService.cs
using Microsoft.ML;
using Microsoft.ML.Data;
using Microsoft.ML.Trainers;
using SmartCommerce.Application.Common.Interfaces;
using SmartCommerce.Domain.Entities;
namespace SmartCommerce.Infrastructure.AI
{
public interface IRecommendationService
{
Task<TrainingResult> TrainRecommendationModelAsync();
Task<List<Recommendation>> GetPersonalizedRecommendationsAsync(Guid userId, int count = 10);
Task<List<Recommendation>> GetSimilarProductsAsync(Guid productId, int count = 5);
Task RecordUserInteractionAsync(UserInteraction interaction);
}
public class AIRecommendationService : IRecommendationService
{
private readonly MLContext _mlContext;
private readonly IApplicationDbContext _context;
private readonly IProductRepository _productRepository;
private ITransformer _model;
private PredictionEngine<ProductInteraction, ProductPrediction> _predictionEngine;
public AIRecommendationService(IApplicationDbContext context, IProductRepository productRepository)
{
_mlContext = new MLContext(seed: 0);
_context = context;
_productRepository = productRepository;
}
public async Task<TrainingResult> TrainRecommendationModelAsync()
{
try
{
// Load training data
var interactions = await LoadTrainingDataAsync();
if (interactions.Count < 100) // Minimum data required
{
return TrainingResult.Failure("Insufficient training data");
}
// Prepare data
var dataView = _mlContext.Data.LoadFromEnumerable(interactions);
// Data preprocessing
var dataProcessPipeline = _mlContext.Transforms.Conversion.MapValueToKey(
outputColumnName: "UserIdEncoded",
inputColumnName: nameof(ProductInteraction.UserId))
.Append(_mlContext.Transforms.Conversion.MapValueToKey(
outputColumnName: "ProductIdEncoded",
inputColumnName: nameof(ProductInteraction.ProductId)));
// Training configuration
var options = new MatrixFactorizationTrainer.Options
{
MatrixColumnIndexColumnName = "UserIdEncoded",
MatrixRowIndexColumnName = "ProductIdEncoded",
LabelColumnName = nameof(ProductInteraction.Rating),
NumberOfIterations = 20,
ApproximationRank = 100,
LearningRate = 0.01
};
var trainingPipeline = dataProcessPipeline.Append(_mlContext.Recommendation().Trainers.MatrixFactorization(options));
// Train model
_model = trainingPipeline.Fit(dataView);
// Create prediction engine
_predictionEngine = _mlContext.Model.CreatePredictionEngine<ProductInteraction, ProductPrediction>(_model);
// Evaluate model
var testData = _mlContext.Data.TrainTestSplit(dataView, testFraction: 0.2);
var predictions = _model.Transform(testData.TestSet);
var metrics = _mlContext.Regression.Evaluate(predictions, labelColumnName: nameof(ProductInteraction.Rating));
return TrainingResult.Success(metrics.RSquared, metrics.RootMeanSquaredError);
}
catch (Exception ex)
{
return TrainingResult.Failure($"Training failed: {ex.Message}");
}
}
public async Task<List<Recommendation>> GetPersonalizedRecommendationsAsync(Guid userId, int count = 10)
{
if (_model == null)
{
// Fallback to popular products if model not trained
return await GetPopularProductsAsync(count);
}
var allProducts = await _productRepository.GetAllAsync();
var recommendations = new List<Recommendation>();
foreach (var product in allProducts)
{
var prediction = _predictionEngine.Predict(new ProductInteraction
{
UserId = userId.ToString(),
ProductId = product.Id.ToString(),
Rating = 0 // This will be predicted
});
recommendations.Add(new Recommendation
{
ProductId = product.Id,
ProductName = product.Name,
Score = prediction.Score,
Confidence = prediction.Confidence,
Reason = "AI Personalized Recommendation"
});
}
return recommendations
.OrderByDescending(r => r.Score)
.Take(count)
.ToList();
}
public async Task<List<Recommendation>> GetSimilarProductsAsync(Guid productId, int count = 5)
{
var targetProduct = await _productRepository.GetByIdAsync(productId);
if (targetProduct == null)
return new List<Recommendation>();
var allProducts = await _productRepository.GetAllAsync();
var similarities = new List<Recommendation>();
foreach (var product in allProducts.Where(p => p.Id != productId))
{
var similarity = CalculateProductSimilarity(targetProduct, product);
similarities.Add(new Recommendation
{
ProductId = product.Id,
ProductName = product.Name,
Score = similarity,
Confidence = 0.8f, // Placeholder
Reason = "Similar Product"
});
}
return similarities
.OrderByDescending(r => r.Score)
.Take(count)
.ToList();
}
public async Task RecordUserInteractionAsync(UserInteraction interaction)
{
await _context.UserInteractions.AddAsync(interaction);
await _context.SaveChangesAsync();
// Trigger model retraining if enough new data
await CheckAndRetrainModelAsync();
}
private async Task<List<ProductInteraction>> LoadTrainingDataAsync()
{
var interactions = await _context.UserInteractions
.Where(ui => ui.InteractionType == InteractionType.Purchase ||
ui.InteractionType == InteractionType.View)
.Select(ui => new ProductInteraction
{
UserId = ui.UserId.ToString(),
ProductId = ui.ProductId.ToString(),
Rating = CalculateRatingFromInteraction(ui.InteractionType)
})
.ToListAsync();
return interactions;
}
private float CalculateRatingFromInteraction(InteractionType interactionType)
{
return interactionType switch
{
InteractionType.Purchase => 5.0f,
InteractionType.View => 1.0f,
InteractionType.AddToCart => 3.0f,
InteractionType.Review => 4.0f,
_ => 0.5f
};
}
private float CalculateProductSimilarity(Product product1, Product product2)
{
// Simple similarity calculation based on category and price
var categorySimilarity = product1.CategoryId == product2.CategoryId ? 1.0f : 0.0f;
var priceDifference = Math.Abs(product1.Price.Amount - product2.Price.Amount);
var maxPrice = Math.Max(product1.Price.Amount, product2.Price.Amount);
var priceSimilarity = maxPrice > 0 ? 1.0f - (priceDifference / maxPrice) : 1.0f;
// Tag similarity
var commonTags = product1.Tags.Select(t => t.Name)
.Intersect(product2.Tags.Select(t => t.Name))
.Count();
var tagSimilarity = commonTags / (float)Math.Max(product1.Tags.Count, product2.Tags.Count);
return (categorySimilarity * 0.4f) + (priceSimilarity * 0.3f) + (tagSimilarity * 0.3f);
}
private async Task<List<Recommendation>> GetPopularProductsAsync(int count)
{
var popularProducts = await _context.Products
.Where(p => p.IsActive && !p.IsDeleted)
.OrderByDescending(p => p.AIScore)
.ThenByDescending(p => p.Reviews.Count)
.Take(count)
.Select(p => new Recommendation
{
ProductId = p.Id,
ProductName = p.Name,
Score = p.AIScore,
Confidence = 0.7f,
Reason = "Popular Product"
})
.ToListAsync();
return popularProducts;
}
private async Task CheckAndRetrainModelAsync()
{
var recentInteractions = await _context.UserInteractions
.Where(ui => ui.Created > DateTime.UtcNow.AddDays(-1))
.CountAsync();
if (recentInteractions >= 1000) // Retrain if 1000 new interactions
{
_ = Task.Run(async () =>
{
await TrainRecommendationModelAsync();
});
}
}
}
public class ProductInteraction
{
public string UserId { get; set; } = string.Empty;
public string ProductId { get; set; } = string.Empty;
public float Rating { get; set; }
}
public class ProductPrediction
{
public float Score { get; set; }
public float Confidence { get; set; }
}
public record Recommendation
{
public Guid ProductId { get; init; }
public string ProductName { get; init; } = string.Empty;
public float Score { get; init; }
public float Confidence { get; init; }
public string Reason { get; init; } = string.Empty;
}
public record TrainingResult(bool Success, string? ErrorMessage = null, double? RSquared = null, double? RMSE = null)
{
public static TrainingResult Success(double rSquared, double rmse) => new(true, null, rSquared, rmse);
public static TrainingResult Failure(string errorMessage) => new(false, errorMessage);
}
}
8. Blazor Frontend
8.1 Blazor WebAssembly Main Application
<!-- Web/Shared/MainLayout.razor -->
@inherits LayoutView
@using SmartCommerce.Web.Components
@using MudBlazor
<MudTheme Provider="ThemeProvider" />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout>
<MudAppBar Elevation="1">
<MudIconButton Icon="Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@ToggleDrawer" />
<MudSpacer />
<MudText Typo="Typo.h6" Class="ml-3">SmartCommerce</MudText>
<MudSpacer />
<MudIconButton Icon="Icons.Material.Filled.Search" Color="Color.Inherit" />
@if (IsAuthenticated)
{
<MudIconButton Icon="Icons.Material.Filled.ShoppingCart" Color="Color.Inherit" />
<MudMenu Icon="@("Icons.Material.Filled.AccountCircle")" IconColor="Color.Inherit" Label="Account">
<MudMenuItem Icon="@("Icons.Material.Filled.Person")" Href="/profile">Profile</MudMenuItem>
<MudMenuItem Icon="@("Icons.Material.Filled.ShoppingBag")" Href="/orders">Orders</MudMenuItem>
<MudMenuItem Icon="@("Icons.Material.Filled.ExitToApp")" OnClick="Logout">Logout</MudMenuItem>
</MudMenu>
}
else
{
<MudButton Variant="Variant.Text" Color="Color.Inherit" Href="/login">Login</MudButton>
<MudButton Variant="Variant.Text" Color="Color.Inherit" Href="/register">Register</MudButton>
}
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Always">
<NavMenu />
</MudDrawer>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.Large" Class="my-4">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
@code {
private bool _drawerOpen = true;
[CascadingParameter]
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
private bool IsAuthenticated { get; set; }
protected override async Task OnInitializedAsync()
{
if (AuthenticationStateTask != null)
{
var authState = await AuthenticationStateTask;
IsAuthenticated = authState.User.Identity?.IsAuthenticated ?? false;
}
}
private void ToggleDrawer()
{
_drawerOpen = !_drawerOpen;
}
private async void Logout()
{
// Implement logout logic
await InvokeAsync(StateHasChanged);
}
}
8.2 Product Listing Component
<!-- Web/Components/ProductGrid.razor -->
@using SmartCommerce.Application.Features.Products.Queries.GetProducts
@using SmartCommerce.Web.Services
@inject IMediator Mediator
@inject ISnackbar Snackbar
@inject IRecommendationService RecommendationService
@inject NavigationManager Navigation
<MudGrid Spacing="2" Justify="Justify.FlexStart">
@if (Products == null)
{
@for (int i = 0; i < 8; i++)
{
<MudItem xs="12" sm="6" md="4" lg="3">
<ProductCardSkeleton />
</MudItem>
}
}
else if (Products.Any())
{
@foreach (var product in Products)
{
<MudItem xs="12" sm="6" md="4" lg="3">
<ProductCard Product="product"
OnAddToCart="AddToCart"
OnQuickView="ShowQuickView" />
</MudItem>
}
@if (HasMore)
{
<MudItem xs="12" Class="text-center my-4">
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
OnClick="LoadMore"
Disabled="Loading"
EndIcon="@(Loading ? Icons.Material.Filled.Refresh : Icons.Material.Filled.Add)">
@(Loading ? "Loading..." : "Load More")
</MudButton>
</MudItem>
}
}
else
{
<MudItem xs="12">
<MudText Align="Align.Center" Typo="Typo.h6" Color="Color.Secondary">
No products found
</MudText>
</MudItem>
}
</MudGrid>
<MudDialog @bind-IsVisible="ShowQuickViewDialog" MaxWidth="MaxWidth.Medium">
<DialogContent>
@if (SelectedProduct != null)
{
<QuickViewDialog Product="SelectedProduct"
OnAddToCart="AddToCartFromDialog"
OnClose="CloseQuickView" />
}
</DialogContent>
</MudDialog>
@code {
[Parameter]
public ProductSearchParameters? SearchParameters { get; set; }
[Parameter]
public EventCallback<ProductDto> OnProductSelected { get; set; }
private List<ProductDto> Products { get; set; } = new();
private ProductDto? SelectedProduct { get; set; }
private bool Loading { get; set; }
private bool HasMore { get; set; }
private int CurrentPage { get; set; } = 1;
private bool ShowQuickViewDialog { get; set; }
protected override async Task OnParametersSetAsync()
{
if (SearchParameters != null)
{
await ResetAndLoadProducts();
}
}
protected override async Task OnInitializedAsync()
{
await LoadProducts();
}
private async Task LoadProducts(bool loadMore = false)
{
if (Loading) return;
Loading = true;
StateHasChanged();
try
{
var query = new GetProductsQuery
{
Page = loadMore ? CurrentPage + 1 : 1,
PageSize = 12,
SearchTerm = SearchParameters?.SearchTerm,
CategoryId = SearchParameters?.CategoryId,
MinPrice = SearchParameters?.MinPrice,
MaxPrice = SearchParameters?.MaxPrice,
SortBy = SearchParameters?.SortBy ?? "name",
SortDirection = SearchParameters?.SortDirection ?? "asc"
};
var result = await Mediator.Send(query);
if (result.Succeeded && result.Data != null)
{
if (loadMore)
{
Products.AddRange(result.Data.Items);
CurrentPage++;
}
else
{
Products = result.Data.Items.ToList();
CurrentPage = 1;
}
HasMore = result.Data.HasNextPage;
// Record view for AI recommendations
foreach (var product in Products)
{
await RecommendationService.RecordProductViewAsync(product.Id);
}
}
else
{
Snackbar.Add("Failed to load products", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Error loading products: {ex.Message}", Severity.Error);
}
finally
{
Loading = false;
StateHasChanged();
}
}
private async Task LoadMore()
{
await LoadProducts(true);
}
private async Task ResetAndLoadProducts()
{
Products.Clear();
CurrentPage = 1;
await LoadProducts();
}
private async Task AddToCart(ProductDto product)
{
try
{
// Implementation would add product to cart
Snackbar.Add($"Added {product.Name} to cart", Severity.Success);
// Record interaction for AI
await RecommendationService.RecordAddToCartAsync(product.Id);
}
catch (Exception ex)
{
Snackbar.Add($"Failed to add to cart: {ex.Message}", Severity.Error);
}
}
private void ShowQuickView(ProductDto product)
{
SelectedProduct = product;
ShowQuickViewDialog = true;
StateHasChanged();
}
private async Task AddToCartFromDialog(ProductDto product)
{
await AddToCart(product);
ShowQuickViewDialog = false;
}
private void CloseQuickView()
{
ShowQuickViewDialog = false;
SelectedProduct = null;
}
private async Task OnProductClick(ProductDto product)
{
if (OnProductSelected.HasDelegate)
{
await OnProductSelected.InvokeAsync(product);
}
else
{
Navigation.NavigateTo($"/products/{product.Id}");
}
}
}
public class ProductSearchParameters
{
public string? SearchTerm { get; set; }
public Guid? CategoryId { get; set; }
public decimal? MinPrice { get; set; }
public decimal? MaxPrice { get; set; }
public string SortBy { get; set; } = "name";
public string SortDirection { get; set; } = "asc";
}
8.3 AI-Powered Product Recommendations Component
<!-- Web/Components/ProductRecommendations.razor -->
@using SmartCommerce.Application.Features.Products.Queries.GetProducts
@inject IMediator Mediator
@inject IRecommendationService RecommendationService
@inject IAuthService AuthService
@if (Recommendations.Any())
{
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" GutterBottom="true">
@Title
@if (!string.IsNullOrEmpty(Explanation))
{
<MudTooltip Text="@Explanation">
<MudIcon Icon="Icons.Material.Filled.Info" Size="Size.Small" Class="ml-2" />
</MudTooltip>
}
</MudText>
<MudGrid Spacing="2">
@foreach (var recommendation in Recommendations)
{
<MudItem xs="6" sm="4" md="3" lg="2">
<MudCard Class="recommendation-card" Elevation="2">
<MudCardContent>
<MudLink Href="@($"/products/{recommendation.ProductId}")" Typo="Typo.body2" Class="product-link">
<MudImage Src="@GetProductImage(recommendation)" Height="120px" Width="100%" />
<MudText Typo="Typo.body2" Class="mt-2 product-name">@recommendation.ProductName</MudText>
<MudChip Color="Color.Secondary" Size="Size.Small" Label="@recommendation.Reason" />
</MudLink>
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
</MudPaper>
}
@code {
[Parameter]
public string Title { get; set; } = "Recommended For You";
[Parameter]
public string? Explanation { get; set; }
[Parameter]
public int Count { get; set; } = 6;
[Parameter]
public Guid? ProductId { get; set; }
private List<RecommendationDto> Recommendations { get; set; } = new();
private Dictionary<Guid, ProductDto> ProductCache { get; set; } = new();
protected override async Task OnInitializedAsync()
{
await LoadRecommendations();
}
protected override async Task OnParametersSetAsync()
{
if (ProductId.HasValue)
{
await LoadSimilarProducts();
}
}
private async Task LoadRecommendations()
{
var userId = await AuthService.GetCurrentUserIdAsync();
if (userId.HasValue)
{
var recommendations = await RecommendationService.GetPersonalizedRecommendationsAsync(userId.Value, Count);
Recommendations = recommendations.Select(r => new RecommendationDto
{
ProductId = r.ProductId,
ProductName = r.ProductName,
Score = r.Score,
Reason = r.Reason
}).ToList();
await LoadProductDetails();
}
}
private async Task LoadSimilarProducts()
{
if (ProductId.HasValue)
{
var recommendations = await RecommendationService.GetSimilarProductsAsync(ProductId.Value, Count);
Recommendations = recommendations.Select(r => new RecommendationDto
{
ProductId = r.ProductId,
ProductName = r.ProductName,
Score = r.Score,
Reason = r.Reason
}).ToList();
await LoadProductDetails();
}
}
private async Task LoadProductDetails()
{
var productIds = Recommendations.Select(r => r.ProductId).ToList();
var query = new GetProductsByIdsQuery { ProductIds = productIds };
var result = await Mediator.Send(query);
if (result.Succeeded && result.Data != null)
{
ProductCache = result.Data.ToDictionary(p => p.Id, p => p);
}
}
private string GetProductImage(RecommendationDto recommendation)
{
if (ProductCache.TryGetValue(recommendation.ProductId, out var product) &&
product.Images.Any())
{
return product.Images.First().ImageUrl;
}
return "/images/placeholder-product.jpg";
}
}
public class RecommendationDto
{
public Guid ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public float Score { get; set; }
public string Reason { get; set; } = string.Empty;
}
9. Real-time Features
9.1 SignalR Real-time Notifications
// Web/Hubs/NotificationHub.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using SmartCommerce.Application.Common.Interfaces;
namespace SmartCommerce.Web.Hubs
{
[Authorize]
public class NotificationHub : Hub
{
private readonly IConnectionManager _connectionManager;
private readonly ILogger<NotificationHub> _logger;
public NotificationHub(IConnectionManager connectionManager, ILogger<NotificationHub> logger)
{
_connectionManager = connectionManager;
_logger = logger;
}
public override async Task OnConnectedAsync()
{
var userId = Context.User?.FindFirst("sub")?.Value;
if (userId != null && Guid.TryParse(userId, out var userGuid))
{
await _connectionManager.AddConnectionAsync(userGuid, Context.ConnectionId);
await Groups.AddToGroupAsync(Context.ConnectionId, $"user_{userId}");
_logger.LogInformation("User {UserId} connected with connection {ConnectionId}", userId, Context.ConnectionId);
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.User?.FindFirst("sub")?.Value;
if (userId != null && Guid.TryParse(userId, out var userGuid))
{
await _connectionManager.RemoveConnectionAsync(userGuid, Context.ConnectionId);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user_{userId}");
_logger.LogInformation("User {UserId} disconnected from connection {ConnectionId}", userId, Context.ConnectionId);
}
await base.OnDisconnectedAsync(exception);
}
public async Task SubscribeToProduct(Guid productId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"product_{productId}");
_logger.LogInformation("User subscribed to product {ProductId}", productId);
}
public async Task UnsubscribeFromProduct(Guid productId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"product_{productId}");
_logger.LogInformation("User unsubscribed from product {ProductId}", productId);
}
public async Task JoinAdminGroup()
{
if (Context.User?.IsInRole("Admin") == true)
{
await Groups.AddToGroupAsync(Context.ConnectionId, "admins");
_logger.LogInformation("Admin user joined admin group");
}
}
}
public interface INotificationClient
{
Task ReceiveNotification(NotificationDto notification);
Task ProductStockUpdated(ProductStockUpdateDto update);
Task OrderStatusChanged(OrderStatusUpdateDto update);
Task PriceChanged(PriceChangeDto change);
Task NewReviewAdded(ProductReviewDto review);
}
public class NotificationService : INotificationService
{
private readonly IHubContext<NotificationHub, INotificationClient> _hubContext;
private readonly IConnectionManager _connectionManager;
private readonly ILogger<NotificationService> _logger;
public NotificationService(
IHubContext<NotificationHub, INotificationClient> hubContext,
IConnectionManager connectionManager,
ILogger<NotificationService> logger)
{
_hubContext = hubContext;
_connectionManager = connectionManager;
_logger = logger;
}
public async Task NotifyUserAsync(Guid userId, NotificationDto notification)
{
try
{
var connections = await _connectionManager.GetConnectionsAsync(userId);
if (connections.Any())
{
await _hubContext.Clients.Clients(connections).ReceiveNotification(notification);
_logger.LogInformation("Sent notification to user {UserId}", userId);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send notification to user {UserId}", userId);
}
}
public async Task NotifyProductSubscribersAsync(Guid productId, ProductStockUpdateDto update)
{
try
{
await _hubContext.Clients.Group($"product_{productId}").ProductStockUpdated(update);
_logger.LogInformation("Notified subscribers of product {ProductId} stock update", productId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to notify product subscribers for product {ProductId}", productId);
}
}
public async Task NotifyOrderUpdateAsync(Guid orderId, Guid userId, OrderStatusUpdateDto update)
{
try
{
await _hubContext.Clients.Group($"user_{userId}").OrderStatusChanged(update);
_logger.LogInformation("Notified user {UserId} of order {OrderId} status update", userId, orderId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to notify user {UserId} of order {OrderId} update", userId, orderId);
}
}
public async Task NotifyAdminsAsync(NotificationDto notification)
{
try
{
await _hubContext.Clients.Group("admins").ReceiveNotification(notification);
_logger.LogInformation("Sent admin notification");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send admin notification");
}
}
public async Task BroadcastPriceChangeAsync(PriceChangeDto change)
{
try
{
await _hubContext.Clients.All.PriceChanged(change);
_logger.LogInformation("Broadcasted price change for product {ProductId}", change.ProductId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to broadcast price change for product {ProductId}", change.ProductId);
}
}
}
}
10. Testing Strategy
10.1 Comprehensive Test Suite
// Tests/Application.UnitTests/Features/Products/GetProductDetailQueryTests.cs
using AutoMapper;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Moq;
using SmartCommerce.Application.Common.Interfaces;
using SmartCommerce.Application.Features.Products.Queries.GetProductDetail;
using SmartCommerce.Domain.Entities;
namespace SmartCommerce.Application.UnitTests.Features.Products.Queries
{
public class GetProductDetailQueryTests
{
private readonly Mock<IApplicationDbContext> _mockContext;
private readonly Mock<IMapper> _mockMapper;
private readonly Mock<ICurrentUserService> _mockCurrentUserService;
private readonly GetProductDetailQueryHandler _handler;
public GetProductDetailQueryTests()
{
_mockContext = new Mock<IApplicationDbContext>();
_mockMapper = new Mock<IMapper>();
_mockCurrentUserService = new Mock<ICurrentUserService>();
_handler = new GetProductDetailQueryHandler(
_mockContext.Object,
_mockMapper.Object,
_mockCurrentUserService.Object);
}
[Fact]
public async Task Handle_WithValidId_ReturnsProductDetail()
{
// Arrange
var productId = Guid.NewGuid();
var product = new Product("Test Product", "Test Description",
new Money(99.99m, "USD"), "TEST-SKU", Guid.NewGuid());
var productsMock = CreateDbSetMock(new List<Product> { product });
_mockContext.Setup(c => c.Products).Returns(productsMock.Object);
var productDetailDto = new ProductDetailDto { Id = productId, Name = "Test Product" };
_mockMapper.Setup(m => m.ConfigurationProvider).Returns(new MapperConfiguration(cfg =>
cfg.CreateMap<Product, ProductDetailDto>()));
_mockMapper.Setup(m => m.ProjectTo<ProductDetailDto>(It.IsAny<IQueryable>(), It.IsAny<object>()))
.Returns(new List<ProductDetailDto> { productDetailDto }.AsQueryable());
var query = new GetProductDetailQuery { Id = productId };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Succeeded.Should().BeTrue();
result.Data.Should().NotBeNull();
result.Data.Name.Should().Be("Test Product");
}
[Fact]
public async Task Handle_WithNonExistentId_ReturnsFailure()
{
// Arrange
var productId = Guid.NewGuid();
var productsMock = CreateDbSetMock(new List<Product>());
_mockContext.Setup(c => c.Products).Returns(productsMock.Object);
var query = new GetProductDetailQuery { Id = productId };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Succeeded.Should().BeFalse();
result.Errors.Should().Contain($"Product with ID {productId} not found.");
}
[Fact]
public async Task Handle_WithInactiveProduct_ReturnsFailure()
{
// Arrange
var productId = Guid.NewGuid();
var product = new Product("Test Product", "Test Description",
new Money(99.99m, "USD"), "TEST-SKU", Guid.NewGuid());
product.Deactivate();
var productsMock = CreateDbSetMock(new List<Product> { product });
_mockContext.Setup(c => c.Products).Returns(productsMock.Object);
var query = new GetProductDetailQuery { Id = productId };
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Succeeded.Should().BeFalse();
}
private static Mock<DbSet<T>> CreateDbSetMock<T>(List<T> elements) where T : class
{
var queryable = elements.AsQueryable();
var dbSetMock = new Mock<DbSet<T>>();
dbSetMock.As<IQueryable<T>>().Setup(m => m.Provider).Returns(queryable.Provider);
dbSetMock.As<IQueryable<T>>().Setup(m => m.Expression).Returns(queryable.Expression);
dbSetMock.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(queryable.ElementType);
dbSetMock.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(queryable.GetEnumerator());
return dbSetMock;
}
}
}
11. Deployment & DevOps
11.1 Docker Configuration
# Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy project files
COPY ["src/SmartCommerce.Web/SmartCommerce.Web.csproj", "src/SmartCommerce.Web/"]
COPY ["src/SmartCommerce.Application/SmartCommerce.Application.csproj", "src/SmartCommerce.Application/"]
COPY ["src/SmartCommerce.Domain/SmartCommerce.Domain.csproj", "src/SmartCommerce.Domain/"]
COPY ["src/SmartCommerce.Infrastructure/SmartCommerce.Infrastructure.csproj", "src/SmartCommerce.Infrastructure/"]
COPY ["src/SmartCommerce.Shared/SmartCommerce.Shared.csproj", "src/SmartCommerce.Shared/"]
# Restore dependencies
RUN dotnet restore "src/SmartCommerce.Web/SmartCommerce.Web.csproj"
# Copy everything else
COPY . .
# Build and publish
WORKDIR "/src/src/SmartCommerce.Web"
RUN dotnet build "SmartCommerce.Web.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "SmartCommerce.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
# Install curl for health checks
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
RUN chown -R appuser:appuser /app
USER appuser
COPY --from=publish /app/publish .
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
ENTRYPOINT ["dotnet", "SmartCommerce.Web.dll"]
11.2 Kubernetes Deployment
yaml
# kubernetes/web-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: smartcommerce-web
labels:
app: smartcommerce-web
spec:
replicas: 3
selector:
matchLabels:
app: smartcommerce-web
template:
metadata:
labels:
app: smartcommerce-web
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "80"
prometheus.io/path: "/metrics"
spec:
containers:
- name: web
image: smartcommerce.azurecr.io/web:latest
ports:
- containerPort: 80
- containerPort: 443
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: smartcommerce-secrets
key: database-connection-string
- name: Azure__KeyVault__Endpoint
valueFrom:
secretKeyRef:
name: smartcommerce-secrets
key: keyvault-endpoint
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 80
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health/startup
port: 80
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 10
---
apiVersion: v1
kind: Service
metadata:
name: smartcommerce-web-service
spec:
selector:
app: smartcommerce-web
ports:
- name: http
port: 80
targetPort: 80
- name: https
port: 443
targetPort: 443
type: LoadBalancer
12. Production Readiness
12.1 Monitoring and Observability
// Infrastructure/Logging/SerilogConfiguration.cs
using Serilog;
using Serilog.Events;
using Serilog.Sinks.ApplicationInsights.Sinks.ApplicationInsights.TelemetryConverters;
using SmartCommerce.Web.Middleware;
namespace SmartCommerce.Infrastructure.Logging
{
public static class SerilogConfiguration
{
public static IHostBuilder UseSerilogConfiguration(this IHostBuilder builder, IConfiguration configuration)
{
return builder.UseSerilog((context, services, loggerConfiguration) =>
{
var applicationInsightsConnectionString = configuration["ApplicationInsights:ConnectionString"];
loggerConfiguration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "SmartCommerce")
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName)
.Enrich.With<ActivityEnricher>()
.Enrich.With<CorrelationIdEnricher>()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
.WriteTo.Debug()
.WriteTo.File(
"logs/smartcommerce-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true);
if (!string.IsNullOrEmpty(applicationInsightsConnectionString))
{
loggerConfiguration.WriteTo.ApplicationInsights(
applicationInsightsConnectionString,
new TraceTelemetryConverter());
}
if (context.HostingEnvironment.IsDevelopment())
{
loggerConfiguration.MinimumLevel.Override("Microsoft", LogEventLevel.Information);
loggerConfiguration.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning);
}
else
{
loggerConfiguration.MinimumLevel.Override("Microsoft", LogEventLevel.Warning);
loggerConfiguration.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning);
}
});
}
}
public class CorrelationIdEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var correlationId = CorrelationIdMiddleware.GetCorrelationId();
if (!string.IsNullOrEmpty(correlationId))
{
var correlationIdProperty = propertyFactory.CreateProperty("CorrelationId", correlationId);
logEvent.AddPropertyIfAbsent(correlationIdProperty);
}
}
}
public class ActivityEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var activity = Activity.Current;
if (activity != null)
{
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TraceId", activity.TraceId));
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("SpanId", activity.SpanId));
}
}
}
}
This comprehensive full-stack ASP.NET Core project demonstrates enterprise-grade development practices with AI integration, cloud-native architecture, and production-ready features. The project showcases real-world e-commerce functionality with intelligent recommendations, real-time updates, and a scalable microservices architecture.
The implementation follows Clean Architecture principles, incorporates domain-driven design, and demonstrates advanced patterns like CQRS, Event Sourcing, and AI-powered features. The project is production-ready with proper testing, monitoring, logging, and deployment configurations.