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

Join the conversation! Your thoughts help the community grow.