feat: Implement inventory domain by replacing generic service context with dedicated inventory context, entities, and repository.

This commit is contained in:
Ho Ngoc Hai
2026-01-18 01:01:07 +07:00
parent f198409e3a
commit 04933be6dd
9 changed files with 155 additions and 58 deletions

View File

@@ -13,11 +13,11 @@ namespace InventoryService.API.Application.Behaviors;
public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly InventoryServiceContext _dbContext;
private readonly InventoryContext _dbContext;
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
public TransactionBehavior(
InventoryServiceContext dbContext,
InventoryContext 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

@@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using InventoryService.Domain.AggregatesModel.SampleAggregate;
using InventoryService.Domain.AggregatesModel.InventoryAggregate;
using InventoryService.Infrastructure.Idempotency;
using InventoryService.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<InventoryServiceContext>(options =>
services.AddDbContext<InventoryContext>(options =>
{
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? configuration["DATABASE_URL"]
@@ -30,7 +30,7 @@ public static class DependencyInjection
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.MigrationsAssembly(typeof(InventoryServiceContext).Assembly.FullName);
npgsqlOptions.MigrationsAssembly(typeof(InventoryContext).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<IInventoryRepository, InventoryRepository>();
// EN: Register idempotency services / VI: Đăng ký idempotency services
services.AddScoped<IRequestManager, RequestManager>();

View File

@@ -0,0 +1,58 @@
// EN: InventoryItem entity configuration.
// VI: Cấu hình entity InventoryItem.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using InventoryService.Domain.AggregatesModel.InventoryAggregate;
namespace InventoryService.Infrastructure.EntityConfigurations;
public class InventoryItemEntityTypeConfiguration : IEntityTypeConfiguration<InventoryItem>
{
public void Configure(EntityTypeBuilder<InventoryItem> builder)
{
builder.ToTable("inventory_items");
builder.HasKey(i => i.Id);
builder.Property(i => i.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property<Guid>("_productId").HasColumnName("product_id").IsRequired();
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
builder.Property<int>("_quantityOnHand").HasColumnName("quantity_on_hand").IsRequired();
builder.Property<int>("_reservedQuantity").HasColumnName("reserved_quantity").IsRequired();
builder.Property<int>("_reorderLevel").HasColumnName("reorder_level").HasDefaultValue(10);
builder.Property<DateTime>("_updatedAt").HasColumnName("updated_at").IsRequired();
// Owned InventoryTransaction collection
builder.OwnsMany(i => i.Transactions, txn =>
{
txn.ToTable("inventory_transactions");
txn.WithOwner().HasForeignKey("InventoryItemId");
txn.Property<Guid>("InventoryItemId").HasColumnName("inventory_item_id");
txn.HasKey(t => t.Id);
txn.Property(t => t.Id).HasColumnName("id").ValueGeneratedNever();
txn.Property(t => t.TypeId).HasColumnName("type_id").IsRequired();
txn.Property<int>("_quantity").HasColumnName("quantity").IsRequired();
txn.Property<Guid?>("_referenceId").HasColumnName("reference_id");
txn.Property<string?>("_notes").HasColumnName("notes").HasMaxLength(500);
txn.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
txn.Ignore(t => t.Type);
txn.Ignore(t => t.Quantity);
txn.Ignore(t => t.ReferenceId);
txn.Ignore(t => t.Notes);
txn.Ignore(t => t.CreatedAt);
});
builder.HasIndex("_productId").HasDatabaseName("ix_inventory_product_id");
builder.HasIndex("_shopId").HasDatabaseName("ix_inventory_shop_id");
builder.Ignore(i => i.ProductId);
builder.Ignore(i => i.ShopId);
builder.Ignore(i => i.QuantityOnHand);
builder.Ignore(i => i.ReservedQuantity);
builder.Ignore(i => i.AvailableQuantity);
builder.Ignore(i => i.ReorderLevel);
builder.Ignore(i => i.UpdatedAt);
}
}

View File

@@ -0,0 +1,28 @@
// EN: TransactionType enumeration configuration.
// VI: Cấu hình TransactionType enumeration.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using InventoryService.Domain.AggregatesModel.InventoryAggregate;
namespace InventoryService.Infrastructure.EntityConfigurations;
public class TransactionTypeEntityTypeConfiguration : IEntityTypeConfiguration<TransactionType>
{
public void Configure(EntityTypeBuilder<TransactionType> builder)
{
builder.ToTable("transaction_types");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property(t => t.Name).HasColumnName("name").HasMaxLength(50).IsRequired();
builder.HasData(
TransactionType.In,
TransactionType.Out,
TransactionType.Adjustment,
TransactionType.Reserve,
TransactionType.Release
);
}
}

View File

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

View File

@@ -1,7 +1,7 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using InventoryService.Domain.AggregatesModel.SampleAggregate;
using InventoryService.Domain.AggregatesModel.InventoryAggregate;
using InventoryService.Domain.SeedWork;
using InventoryService.Infrastructure.EntityConfigurations;
@@ -11,16 +11,16 @@ namespace InventoryService.Infrastructure;
/// EN: EF Core DbContext for InventoryService.
/// VI: EF Core DbContext cho InventoryService.
/// </summary>
public class InventoryServiceContext : DbContext, IUnitOfWork
public class InventoryContext : DbContext, IUnitOfWork
{
private readonly IMediator _mediator;
private IDbContextTransaction? _currentTransaction;
/// <summary>
/// EN: Samples table.
/// VI: Bảng Samples.
/// EN: Inventory items table.
/// VI: Bảng Inventory items.
/// </summary>
public DbSet<Sample> Samples => Set<Sample>();
public DbSet<InventoryItem> InventoryItems => Set<InventoryItem>();
/// <summary>
/// EN: Read-only access to current transaction.
@@ -34,64 +34,35 @@ public class InventoryServiceContext : DbContext, IUnitOfWork
/// </summary>
public bool HasActiveTransaction => _currentTransaction != null;
public InventoryServiceContext(DbContextOptions<InventoryServiceContext> options) : base(options)
{
_mediator = null!;
}
public InventoryServiceContext(DbContextOptions<InventoryServiceContext> options, IMediator mediator) : base(options)
public InventoryContext(DbContextOptions<InventoryContext> options, IMediator mediator) : base(options)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
System.Diagnostics.Debug.WriteLine("InventoryServiceContext::ctor - " + GetHashCode());
System.Diagnostics.Debug.WriteLine("InventoryContext::ctor - " + GetHashCode());
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// EN: Apply entity configurations
// VI: Áp dụng các cấu hình entity
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new InventoryItemEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new TransactionTypeEntityTypeConfiguration());
}
/// <summary>
/// EN: Save entities and dispatch domain events.
/// VI: Lưu entities và dispatch domain events.
/// </summary>
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
{
// EN: Dispatch domain events before saving (side effects)
// VI: Dispatch domain events trước khi lưu (side effects)
await DispatchDomainEventsAsync();
// EN: Save changes to database
// VI: Lưu thay đổi vào database
await base.SaveChangesAsync(cancellationToken);
return true;
}
/// <summary>
/// EN: Begin a new transaction if none is active.
/// VI: Bắt đầu một transaction mới nếu không có transaction nào đang hoạt động.
/// </summary>
public async Task<IDbContextTransaction?> BeginTransactionAsync()
{
if (_currentTransaction != null) return null;
_currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted);
return _currentTransaction;
}
/// <summary>
/// EN: Commit the current transaction.
/// VI: Commit transaction hiện tại.
/// </summary>
public async Task CommitTransactionAsync(IDbContextTransaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction);
if (transaction != _currentTransaction)
throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current");
@@ -115,10 +86,6 @@ public class InventoryServiceContext : DbContext, IUnitOfWork
}
}
/// <summary>
/// EN: Rollback the current transaction.
/// VI: Rollback transaction hiện tại.
/// </summary>
public void RollbackTransaction()
{
try
@@ -135,10 +102,6 @@ public class InventoryServiceContext : DbContext, IUnitOfWork
}
}
/// <summary>
/// EN: Dispatch all domain events from tracked entities.
/// VI: Dispatch tất cả domain events từ các entities đang được track.
/// </summary>
private async Task DispatchDomainEventsAsync()
{
var domainEntities = ChangeTracker

View File

@@ -0,0 +1,44 @@
using InventoryService.Domain.AggregatesModel.InventoryAggregate;
using InventoryService.Domain.SeedWork;
using Microsoft.EntityFrameworkCore;
namespace InventoryService.Infrastructure.Repositories;
public class InventoryRepository : IInventoryRepository
{
private readonly InventoryContext _context;
public IUnitOfWork UnitOfWork => _context;
public InventoryRepository(InventoryContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public InventoryItem Add(InventoryItem item)
{
return _context.InventoryItems.Add(item).Entity;
}
public void Update(InventoryItem item)
{
_context.Entry(item).State = EntityState.Modified;
}
public async Task<InventoryItem?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.InventoryItems.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
}
public async Task<InventoryItem?> GetByProductIdAsync(Guid productId, Guid shopId, CancellationToken cancellationToken = default)
{
return await _context.InventoryItems
.FirstOrDefaultAsync(i => i.ProductId == productId && i.ShopId == shopId, cancellationToken);
}
public async Task<IEnumerable<InventoryItem>> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
{
return await _context.InventoryItems
.Where(i => i.ShopId == shopId)
.ToListAsync(cancellationToken);
}
}

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<InventoryServiceContext>));
d => d.ServiceType == typeof(DbContextOptions<InventoryContext>));
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(InventoryServiceContext));
d => d.ServiceType == typeof(InventoryContext));
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<InventoryServiceContext>(options =>
services.AddDbContext<InventoryContext>(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<InventoryServiceContext>();
var db = scope.ServiceProvider.GetRequiredService<InventoryContext>();
db.Database.EnsureCreated();
});
}