feat: Remove sample aggregates, introduce BillingAccount aggregate, and refactor CatalogServiceContext to CatalogContext.

This commit is contained in:
Ho Ngoc Hai
2026-01-18 00:36:53 +07:00
parent 6263ab4932
commit c9fdb56cb8
14 changed files with 164 additions and 487 deletions

View File

@@ -0,0 +1,146 @@
using AdsBillingService.Domain.SeedWork;
namespace AdsBillingService.Domain.AggregatesModel.BillingAccountAggregate;
/// <summary>
/// EN: Billing account aggregate root - manages advertiser billing and payments.
/// VI: Billing account aggregate root - quản lý thanh toán và billing của advertiser.
/// </summary>
public class BillingAccount : Entity, IAggregateRoot
{
private Guid _advertiserId;
private Guid? _walletId;
private PaymentMethodType _paymentMethod;
private BillingThreshold? _threshold;
private AccountStatus _status;
private decimal _balance;
private decimal _creditLimit;
public Guid AdvertiserId => _advertiserId;
public Guid? WalletId => _walletId;
public PaymentMethodType PaymentMethod => _paymentMethod;
public BillingThreshold? Threshold => _threshold;
public AccountStatus Status => _status;
public decimal Balance => _balance;
public decimal CreditLimit => _creditLimit;
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
protected BillingAccount() { }
public BillingAccount(Guid advertiserId, Guid? walletId, PaymentMethodType paymentMethod)
{
Id = Guid.NewGuid();
_advertiserId = advertiserId;
_walletId = walletId;
_paymentMethod = paymentMethod;
_status = AccountStatus.Active;
_balance = 0;
_creditLimit = 0;
CreatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Set auto-charge threshold for prepaid accounts.
/// VI: Thiết lập ngưỡng tự động nạp tiền cho tài khoản trả trước.
/// </summary>
public void SetThreshold(decimal amount, bool autoCharge)
{
_threshold = new BillingThreshold(amount, autoCharge);
_UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Deduct amount from balance (for prepaid).
/// VI: Trừ tiền từ số dư (cho trả trước).
/// </summary>
public void DeductBalance(decimal amount)
{
if (_paymentMethod != PaymentMethodType.Prepaid)
throw new AdsBillingDomainException("Can only deduct from prepaid accounts");
if (_balance < amount)
throw new AdsBillingDomainException("Insufficient balance");
_balance -= amount;
_UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Add balance (from wallet top-up).
/// VI: Thêm số dư (từ nạp ví).
/// </summary>
public void AddBalance(decimal amount)
{
if (amount <= 0)
throw new AdsBillingDomainException("Amount must be positive");
_balance += amount;
_UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Suspend account (e.g., due to payment issues).
/// VI: Tạm ngưng tài khoản (ví dụ do vấn đề thanh toán).
/// </summary>
public void Suspend()
{
_status = AccountStatus.Suspended;
_UpdatedAt = DateTime.UtcNow;
}
private DateTime? _UpdatedAt
{
get => UpdatedAt;
set => UpdatedAt = value;
}
}
/// <summary>
/// EN: Payment method type enumeration.
/// VI: Enum loại phương thức thanh toán.
/// </summary>
public enum PaymentMethodType
{
Prepaid = 1, // Pay upfront via Wallet
Postpaid = 2, // Monthly invoice
CreditCard = 3 // Auto-charge credit card
}
/// <summary>
/// EN: Account status enumeration.
/// VI: Enum trạng thái tài khoản.
/// </summary>
public enum AccountStatus
{
Active = 1,
Suspended = 2,
Closed = 3
}
/// <summary>
/// EN: Billing threshold value object.
/// VI: Value object ngưỡng billing.
/// </summary>
public class BillingThreshold : ValueObject
{
public decimal Amount { get; private set; }
public bool AutoCharge { get; private set; }
protected BillingThreshold() { }
public BillingThreshold(decimal amount, bool autoCharge)
{
if (amount <= 0)
throw new AdsBillingDomainException("Threshold amount must be positive");
Amount = amount;
AutoCharge = autoCharge;
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return AutoCharge;
}
}

View File

@@ -1,61 +0,0 @@
using AdsBillingService.Domain.SeedWork;
namespace AdsBillingService.Domain.AggregatesModel.SampleAggregate;
/// <summary>
/// EN: Repository interface for Sample aggregate.
/// VI: Interface repository cho Sample aggregate.
/// </summary>
/// <remarks>
/// EN: Following repository pattern, this interface defines the contract
/// for data access operations on Sample aggregate.
/// VI: Theo pattern repository, interface này định nghĩa contract
/// cho các thao tác truy cập dữ liệu trên Sample aggregate.
/// </remarks>
public interface ISampleRepository : IRepository<Sample>
{
/// <summary>
/// EN: Get a sample by its ID.
/// VI: Lấy một sample theo ID.
/// </summary>
/// <param name="sampleId">EN: The sample ID / VI: ID của sample</param>
/// <returns>EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy</returns>
Task<Sample?> GetAsync(Guid sampleId);
/// <summary>
/// EN: Get all samples.
/// VI: Lấy tất cả samples.
/// </summary>
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
Task<IEnumerable<Sample>> GetAllAsync();
/// <summary>
/// EN: Add a new sample.
/// VI: Thêm một sample mới.
/// </summary>
/// <param name="sample">EN: The sample to add / VI: Sample cần thêm</param>
/// <returns>EN: The added sample / VI: Sample đã thêm</returns>
Sample Add(Sample sample);
/// <summary>
/// EN: Update an existing sample.
/// VI: Cập nhật một sample đã tồn tại.
/// </summary>
/// <param name="sample">EN: The sample to update / VI: Sample cần cập nhật</param>
void Update(Sample sample);
/// <summary>
/// EN: Delete a sample.
/// VI: Xóa một sample.
/// </summary>
/// <param name="sample">EN: The sample to delete / VI: Sample cần xóa</param>
void Delete(Sample sample);
/// <summary>
/// EN: Get samples by status.
/// VI: Lấy samples theo trạng thái.
/// </summary>
/// <param name="statusId">EN: The status ID / VI: ID trạng thái</param>
/// <returns>EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước</returns>
Task<IEnumerable<Sample>> GetByStatusAsync(int statusId);
}

View File

@@ -1,158 +0,0 @@
using AdsBillingService.Domain.Events;
using AdsBillingService.Domain.Exceptions;
using AdsBillingService.Domain.SeedWork;
namespace AdsBillingService.Domain.AggregatesModel.SampleAggregate;
/// <summary>
/// EN: Sample aggregate root demonstrating DDD patterns.
/// VI: Sample aggregate root minh họa các pattern DDD.
/// </summary>
public class Sample : Entity, IAggregateRoot
{
// EN: Private fields for encapsulation
// VI: Fields private để đóng gói
private string _name = null!;
private string? _description;
private SampleStatus _status = null!;
private DateTime _createdAt;
private DateTime? _updatedAt;
/// <summary>
/// EN: Sample name (required).
/// VI: Tên sample (bắt buộc).
/// </summary>
public string Name => _name;
/// <summary>
/// EN: Optional description.
/// VI: Mô tả tùy chọn.
/// </summary>
public string? Description => _description;
/// <summary>
/// EN: Current status.
/// VI: Trạng thái hiện tại.
/// </summary>
public SampleStatus Status => _status;
/// <summary>
/// EN: Status ID for EF Core mapping.
/// VI: ID trạng thái cho EF Core mapping.
/// </summary>
public int StatusId { get; private set; }
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: Last update timestamp.
/// VI: Thời gian cập nhật cuối.
/// </summary>
public DateTime? UpdatedAt => _updatedAt;
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected Sample()
{
}
/// <summary>
/// EN: Create a new Sample with required information.
/// VI: Tạo một Sample mới với thông tin bắt buộc.
/// </summary>
/// <param name="name">EN: Sample name / VI: Tên sample</param>
/// <param name="description">EN: Optional description / VI: Mô tả tùy chọn</param>
public Sample(string name, string? description = null) : this()
{
if (string.IsNullOrWhiteSpace(name))
throw new SampleDomainException("Sample name cannot be empty");
Id = Guid.NewGuid();
_name = name;
_description = description;
_status = SampleStatus.Draft;
StatusId = SampleStatus.Draft.Id;
_createdAt = DateTime.UtcNow;
// EN: Add domain event for creation
// VI: Thêm domain event cho việc tạo
AddDomainEvent(new SampleCreatedDomainEvent(this));
}
/// <summary>
/// EN: Update sample information.
/// VI: Cập nhật thông tin sample.
/// </summary>
public void Update(string name, string? description)
{
if (string.IsNullOrWhiteSpace(name))
throw new SampleDomainException("Sample name cannot be empty");
if (_status == SampleStatus.Cancelled)
throw new SampleDomainException("Cannot update a cancelled sample");
_name = name;
_description = description;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Activate the sample.
/// VI: Kích hoạt sample.
/// </summary>
public void Activate()
{
if (_status != SampleStatus.Draft)
throw new SampleDomainException("Only draft samples can be activated");
var previousStatus = _status;
_status = SampleStatus.Active;
StatusId = SampleStatus.Active.Id;
_updatedAt = DateTime.UtcNow;
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
}
/// <summary>
/// EN: Complete the sample.
/// VI: Hoàn thành sample.
/// </summary>
public void Complete()
{
if (_status != SampleStatus.Active)
throw new SampleDomainException("Only active samples can be completed");
var previousStatus = _status;
_status = SampleStatus.Completed;
StatusId = SampleStatus.Completed.Id;
_updatedAt = DateTime.UtcNow;
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
}
/// <summary>
/// EN: Cancel the sample.
/// VI: Hủy sample.
/// </summary>
public void Cancel()
{
if (_status == SampleStatus.Completed)
throw new SampleDomainException("Cannot cancel a completed sample");
if (_status == SampleStatus.Cancelled)
throw new SampleDomainException("Sample is already cancelled");
var previousStatus = _status;
_status = SampleStatus.Cancelled;
StatusId = SampleStatus.Cancelled.Id;
_updatedAt = DateTime.UtcNow;
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
}
}

View File

@@ -1,77 +0,0 @@
using AdsBillingService.Domain.SeedWork;
namespace AdsBillingService.Domain.AggregatesModel.SampleAggregate;
/// <summary>
/// EN: Sample status enumeration following type-safe enum pattern.
/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu.
/// </summary>
public class SampleStatus : Enumeration
{
/// <summary>
/// EN: Draft status - initial state
/// VI: Trạng thái nháp - trạng thái ban đầu
/// </summary>
public static SampleStatus Draft = new(1, nameof(Draft));
/// <summary>
/// EN: Active status - ready for use
/// VI: Trạng thái hoạt động - sẵn sàng sử dụng
/// </summary>
public static SampleStatus Active = new(2, nameof(Active));
/// <summary>
/// EN: Completed status - finished processing
/// VI: Trạng thái hoàn thành - đã xử lý xong
/// </summary>
public static SampleStatus Completed = new(3, nameof(Completed));
/// <summary>
/// EN: Cancelled status - cancelled by user
/// VI: Trạng thái đã hủy - bị hủy bởi người dùng
/// </summary>
public static SampleStatus Cancelled = new(4, nameof(Cancelled));
public SampleStatus(int id, string name) : base(id, name)
{
}
/// <summary>
/// EN: Get all available statuses.
/// VI: Lấy tất cả các trạng thái có sẵn.
/// </summary>
public static IEnumerable<SampleStatus> List() => GetAll<SampleStatus>();
/// <summary>
/// EN: Parse status from name.
/// VI: Parse trạng thái từ tên.
/// </summary>
public static SampleStatus FromName(string name)
{
var status = List().SingleOrDefault(s =>
string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
if (status is null)
{
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
}
return status;
}
/// <summary>
/// EN: Parse status from ID.
/// VI: Parse trạng thái từ ID.
/// </summary>
public static SampleStatus From(int id)
{
var status = List().SingleOrDefault(s => s.Id == id);
if (status is null)
{
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
}
return status;
}
}

View File

@@ -23,8 +23,8 @@ public class AdsController : ControllerBase
}
/// <summary>
/// EN: Serve an ad based on user context (< 100ms target).
/// VI: Serve quảng cáo dựa trên ngữ cảnh người dùng (mục tiêu < 100ms).
/// EN: Serve an ad based on user context (less than 100ms target).
/// VI: Serve quảng cáo dựa trên ngữ cảnh người dùng (mục tiêu dưới 100ms).
/// </summary>
[HttpPost("serve")]
[ProducesResponseType(typeof(ServedAdDto), StatusCodes.Status200OK)]

View File

@@ -13,11 +13,11 @@ namespace CatalogService.API.Application.Behaviors;
public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly CatalogServiceContext _dbContext;
private readonly CatalogContext _dbContext;
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
public TransactionBehavior(
CatalogServiceContext dbContext,
CatalogContext dbContext,
ILogger<TransactionBehavior<TRequest, TResponse>> logger)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));

View File

@@ -14,6 +14,10 @@
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />

View File

@@ -40,11 +40,6 @@ public class CatalogContext : DbContext, IUnitOfWork
/// </summary>
public bool HasActiveTransaction => _currentTransaction != null;
public CatalogContext(DbContextOptions<CatalogContext> options) : base(options)
{
_mediator = null!;
}
public CatalogContext(DbContextOptions<CatalogContext> options, IMediator mediator) : base(options)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));

View File

@@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using CatalogService.Domain.AggregatesModel.SampleAggregate;
using CatalogService.Domain.AggregatesModel.ProductAggregate;
using CatalogService.Infrastructure.Idempotency;
using CatalogService.Infrastructure.Repositories;
@@ -22,7 +22,7 @@ public static class DependencyInjection
IConfiguration configuration)
{
// EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL
services.AddDbContext<CatalogServiceContext>(options =>
services.AddDbContext<CatalogContext>(options =>
{
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? configuration["DATABASE_URL"]
@@ -30,7 +30,7 @@ public static class DependencyInjection
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.MigrationsAssembly(typeof(CatalogServiceContext).Assembly.FullName);
npgsqlOptions.MigrationsAssembly(typeof(CatalogContext).Assembly.FullName);
npgsqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
@@ -47,7 +47,7 @@ public static class DependencyInjection
});
// EN: Register repositories / VI: Đăng ký repositories
services.AddScoped<ISampleRepository, SampleRepository>();
services.AddScoped<IProductRepository, ProductRepository>();
// EN: Register idempotency services / VI: Đăng ký idempotency services
services.AddScoped<IRequestManager, RequestManager>();

View File

@@ -1,61 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using CatalogService.Domain.AggregatesModel.SampleAggregate;
namespace CatalogService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Sample entity.
/// VI: Cấu hình EF Core cho entity Sample.
/// </summary>
public class SampleEntityTypeConfiguration : IEntityTypeConfiguration<Sample>
{
public void Configure(EntityTypeBuilder<Sample> builder)
{
// EN: Table name / VI: Tên bảng
builder.ToTable("samples");
// EN: Primary key / VI: Khóa chính
builder.HasKey(s => s.Id);
// EN: Ignore domain events (not persisted)
// VI: Bỏ qua domain events (không lưu)
builder.Ignore(s => s.DomainEvents);
// EN: Properties / VI: Các thuộc tính
builder.Property(s => s.Id)
.HasColumnName("id")
.IsRequired();
builder.Property<string>("_name")
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.Property<string?>("_description")
.HasColumnName("description")
.HasMaxLength(1000);
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
builder.Property<DateTime?>("_updatedAt")
.HasColumnName("updated_at");
// EN: Status relationship / VI: Quan hệ với Status
builder.Property(s => s.StatusId)
.HasColumnName("status_id")
.IsRequired();
builder.HasOne(s => s.Status)
.WithMany()
.HasForeignKey(s => s.StatusId)
.OnDelete(DeleteBehavior.Restrict);
// EN: Indexes / VI: Các index
builder.HasIndex("_name");
builder.HasIndex(s => s.StatusId);
builder.HasIndex("_createdAt");
}
}

View File

@@ -1,39 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using CatalogService.Domain.AggregatesModel.SampleAggregate;
namespace CatalogService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for SampleStatus enumeration.
/// VI: Cấu hình EF Core cho enumeration SampleStatus.
/// </summary>
public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration<SampleStatus>
{
public void Configure(EntityTypeBuilder<SampleStatus> builder)
{
// EN: Table name / VI: Tên bảng
builder.ToTable("sample_statuses");
// EN: Primary key / VI: Khóa chính
builder.HasKey(s => s.Id);
builder.Property(s => s.Id)
.HasColumnName("id")
.ValueGeneratedNever()
.IsRequired();
builder.Property(s => s.Name)
.HasColumnName("name")
.HasMaxLength(50)
.IsRequired();
// EN: Seed initial data / VI: Seed dữ liệu ban đầu
builder.HasData(
SampleStatus.Draft,
SampleStatus.Active,
SampleStatus.Completed,
SampleStatus.Cancelled
);
}
}

View File

@@ -8,9 +8,9 @@ namespace CatalogService.Infrastructure.Idempotency;
/// </summary>
public class RequestManager : IRequestManager
{
private readonly CatalogServiceContext _context;
private readonly CatalogContext _context;
public RequestManager(CatalogServiceContext context)
public RequestManager(CatalogContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}

View File

@@ -1,72 +0,0 @@
using Microsoft.EntityFrameworkCore;
using CatalogService.Domain.AggregatesModel.SampleAggregate;
using CatalogService.Domain.SeedWork;
namespace CatalogService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for Sample aggregate.
/// VI: Triển khai repository cho Sample aggregate.
/// </summary>
public class SampleRepository : ISampleRepository
{
private readonly CatalogServiceContext _context;
/// <summary>
/// EN: Unit of work for transaction management.
/// VI: Unit of work cho quản lý transaction.
/// </summary>
public IUnitOfWork UnitOfWork => _context;
public SampleRepository(CatalogServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc/>
public async Task<Sample?> GetAsync(Guid sampleId)
{
var sample = await _context.Samples
.Include(s => s.Status)
.FirstOrDefaultAsync(s => s.Id == sampleId);
return sample;
}
/// <inheritdoc/>
public async Task<IEnumerable<Sample>> GetAllAsync()
{
return await _context.Samples
.Include(s => s.Status)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
}
/// <inheritdoc/>
public Sample Add(Sample sample)
{
return _context.Samples.Add(sample).Entity;
}
/// <inheritdoc/>
public void Update(Sample sample)
{
_context.Entry(sample).State = EntityState.Modified;
}
/// <inheritdoc/>
public void Delete(Sample sample)
{
_context.Samples.Remove(sample);
}
/// <inheritdoc/>
public async Task<IEnumerable<Sample>> GetByStatusAsync(int statusId)
{
return await _context.Samples
.Include(s => s.Status)
.Where(s => s.StatusId == statusId)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
}
}

View File

@@ -21,7 +21,7 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
// EN: Remove the existing DbContext registration
// VI: Xóa đăng ký DbContext hiện tại
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<CatalogServiceContext>));
d => d.ServiceType == typeof(DbContextOptions<CatalogContext>));
if (descriptor != null)
{
@@ -31,7 +31,7 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
// EN: Remove DbContext service
// VI: Xóa DbContext service
var dbContextDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(CatalogServiceContext));
d => d.ServiceType == typeof(CatalogContext));
if (dbContextDescriptor != null)
{
@@ -40,7 +40,7 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
// EN: Add in-memory database for testing
// VI: Thêm in-memory database để test
services.AddDbContext<CatalogServiceContext>(options =>
services.AddDbContext<CatalogContext>(options =>
{
options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString());
});
@@ -49,7 +49,7 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
// VI: Đảm bảo database được tạo với seed data
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<CatalogServiceContext>();
var db = scope.ServiceProvider.GetRequiredService<CatalogContext>();
db.Database.EnsureCreated();
});
}