Production
Bounded Context
Thiết kế DDD đầy đủ cho Production Module — Aggregate Root, Value Objects, Business Rules, và implementation C# .NET 9 theo pattern CQRS + Event Sourcing.
Production Bounded Context là gì
Production BC chịu trách nhiệm quản lý toàn bộ vòng đời của Lệnh Sản Xuất (Production Order) — từ khi được tạo ra đến khi hoàn thành hoặc bị hủy. Đây là aggregate phức tạp nhất và trung tâm nhất của hệ thống MES.
- Aggregate Root:
ProductionOrder - Kafka Topic:
production.events - Database: Event Store (write) +
production_ordersprojection (read) - Các module phụ thuộc: Inventory (lock NVL), Quality (trigger inspection), Finance (tính chi phí)
Tất cả thay đổi trong production order đi qua một điểm duy nhất — Aggregate Root — đảm bảo tính nhất quán của business rules và invariants. Không có code nào được phép thay đổi trạng thái production order mà không đi qua aggregate này.
State Machine — ProductionOrder
Aggregate properties
| Property | Type | Mô tả | Mutability |
|---|---|---|---|
Id | OrderId | Identity duy nhất | Immutable |
OrderNumber | OrderNumber | Số lệnh (PO-2026-001234) | Immutable |
Product | ProductSpec | Sản phẩm sản xuất | Immutable |
PlannedQty | Quantity | Số lượng kế hoạch | Immutable |
ActualQty | Quantity | Số lượng thực tế (tích lũy) | Mutable |
Status | ProductionStatus | Trạng thái hiện tại | Mutable |
MachineId | MachineId | Máy được giao | Immutable |
OperatorId | OperatorId? | Operator đang chạy | Mutable |
Shift | Shift | Ca sản xuất | Immutable |
TenantId | string | Multi-tenancy | Immutable |
Version | int | Optimistic locking version | Mutable |
Value Objects
Value Objects là immutable, không có identity, so sánh bằng value. Mỗi VO tự validate khi khởi tạo — không bao giờ tạo được VO ở trạng thái invalid.
OrderId.New() → gen UUIDPO-YYYY-NNNNNNQuantity.Zero(unit) allowedBusiness Rules — Invariants
Những rule này được enforce bởi Aggregate, không thể bị bypass. Nếu vi phạm → throw ProductionDomainException.
| Rule | Điều kiện | Lý do |
|---|---|---|
| R01 — Start từ đúng trạng thái | Status phải là Planned hoặc Released | Tránh restart lệnh đang chạy |
| R02 — Complete khi đang chạy | Status phải là InProgress | Logic nhất quán |
| R03 — Số lượng dương | PlannedQty.Value > 0 | Lệnh phải có số lượng |
| R04 — Không vượt quá kế hoạch | ActualQty ≤ PlannedQty × 1.1 | Buffer 10% cho biến động |
| R05 — Ghi sản lượng khi chạy | Status phải là InProgress | Không ghi khi đang pause |
| R06 — Hủy chỉ khi chưa hoàn thành | Status không phải Completed | Lệnh đã hoàn thành không được hủy |
| R07 — Lý do hủy bắt buộc | CancellationReason không blank khi hủy từ InProgress | Audit trail |
| R08 — OrderNumber unique per tenant | Enforce ở Application layer + DB unique constraint | Không trùng số lệnh |
Cấu trúc thư mục .NET 9
├── Core/ ← Domain layer (không phụ thuộc gì)
│ ├── ERP.MES.Core.csproj
│ ├── Shared/
│ │ ├── AggregateRoot.cs ← Base aggregate root
│ │ ├── DomainEvent.cs ← Base domain event
│ │ └── IRepository.cs ← Repository interface
│ └── Production/
│ ├── ProductionOrder.cs ← Aggregate Root
│ ├── Events/ProductionOrderEvents.cs ← Domain Events
│ ├── ValueObjects/ProductionOrderVOs.cs ← Value Objects
│ └── Exceptions/ProductionDomainException.cs
├── Application/ ← Use cases, CQRS
│ ├── ERP.MES.Application.csproj
│ ├── Shared/
│ │ └── ICommandHandler.cs
│ └── Production/
│ ├── Commands/
│ │ ├── CreateProductionOrder.cs
│ │ ├── StartProductionOrder.cs
│ │ └── CompleteProductionOrder.cs
│ └── Queries/
│ └── GetProductionOrderById.cs
└── Infrastructure/ ← DB, Kafka (Phase 2)
Implementation — C# .NET 9
Value Objects
namespace ERP.MES.Core.Production.ValueObjects;
public record OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public static OrderId From(Guid id) => new(id);
public static OrderId From(string s) => new(Guid.Parse(s));
public override string ToString() => Value.ToString();
}
public record OrderNumber
{
public string Value { get; }
public OrderNumber(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Order number không được để trống.");
var v = value.Trim().ToUpper();
if (v.Length > 50)
throw new ArgumentException("Order number tối đa 50 ký tự.");
Value = v;
}
public static OrderNumber Generate(int sequence, int year = 0)
{
var y = year == 0 ? DateTime.UtcNow.Year : year;
return new OrderNumber($"PO-{y}-{sequence:D6}");
}
public override string ToString() => Value;
}
public record ProductSpec(Guid ProductId, string Code, string Name)
{
public ProductSpec(Guid productId, string code, string name)
: this(productId, code, name)
{
if (productId == Guid.Empty) throw new ArgumentException("ProductId không hợp lệ.");
if (string.IsNullOrWhiteSpace(code)) throw new ArgumentException("Product code trống.");
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Product name trống.");
}
}
public record Quantity
{
public decimal Value { get; }
public string Unit { get; }
public Quantity(decimal value, string unit)
{
if (string.IsNullOrWhiteSpace(unit)) throw new ArgumentException("Unit trống.");
if (value < 0) throw new ArgumentException("Quantity không được âm.");
Value = value;
Unit = unit.Trim().ToUpper();
}
public static Quantity Zero(string unit) => new(0, unit);
public Quantity Add(Quantity other)
{
if (Unit != other.Unit)
throw new InvalidOperationException($"Không thể cộng {Unit} với {other.Unit}");
return new Quantity(Value + other.Value, Unit);
}
public bool IsGreaterThan(Quantity other) => Unit == other.Unit && Value > other.Value;
public bool IsGreaterOrEqual(Quantity other) => Unit == other.Unit && Value >= other.Value;
public override string ToString() => $"{Value:G} {Unit}";
}
public record MachineId(Guid Value)
{
public static MachineId From(Guid id) => new(id);
public override string ToString() => Value.ToString();
}
public record OperatorId(Guid Value)
{
public static OperatorId From(Guid id) => new(id);
public override string ToString() => Value.ToString();
}
public enum ProductionStatus
{
Planned = 1,
Released = 2,
InProgress = 3,
OnHold = 4,
Completed = 5,
Cancelled = 6
}
public enum Shift { Morning, Afternoon, Night }
Domain Events
namespace ERP.MES.Core.Production.Events;
using ERP.MES.Core.Production.ValueObjects;
using ERP.MES.Core.Shared;
public sealed record ProductionOrderCreated(
OrderId OrderId,
OrderNumber OrderNumber,
ProductSpec Product,
Quantity PlannedQty,
MachineId MachineId,
Shift Shift,
string TenantId,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionOrderReleased(
OrderId OrderId,
string ReleasedBy,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionStarted(
OrderId OrderId,
OperatorId OperatorId,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionOutputRecorded(
OrderId OrderId,
Quantity Quantity,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionOrderPaused(
OrderId OrderId,
string Reason,
string ReasonCode,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionOrderResumed(
OrderId OrderId,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionOrderCompleted(
OrderId OrderId,
Quantity ActualQty,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionOrderCancelled(
OrderId OrderId,
string Reason,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
ProductionOrder Aggregate Root
namespace ERP.MES.Core.Production;
using ERP.MES.Core.Production.Events;
using ERP.MES.Core.Production.Exceptions;
using ERP.MES.Core.Production.ValueObjects;
using ERP.MES.Core.Shared;
public sealed class ProductionOrder : AggregateRoot<OrderId>
{
public OrderNumber OrderNumber { get; private set; } = null!;
public ProductSpec Product { get; private set; } = null!;
public Quantity PlannedQty { get; private set; } = null!;
public Quantity ActualQty { get; private set; } = null!;
public ProductionStatus Status { get; private set; }
public MachineId MachineId { get; private set; } = null!;
public OperatorId? OperatorId { get; private set; }
public Shift Shift { get; private set; }
public string TenantId { get; private set; } = null!;
private const decimal MaxOutputBuffer = 1.1m; // 10% buffer
private ProductionOrder() { } // EF / reconstitution
// ── Factory ──────────────────────────────────────────────────
public static ProductionOrder Create(
OrderNumber number,
ProductSpec product,
Quantity plannedQty,
MachineId machineId,
Shift shift,
string tenantId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var order = new ProductionOrder();
order.Raise(new ProductionOrderCreated(
OrderId.New(), number, product, plannedQty,
machineId, shift, tenantId,
DateTimeOffset.UtcNow));
return order;
}
// ── Reconstitution từ Event Store ───────────────────────────
public static ProductionOrder Reconstitute(IEnumerable<DomainEvent> events)
{
var order = new ProductionOrder();
foreach (var e in events)
order.Apply(e);
return order;
}
// ── Commands ─────────────────────────────────────────────────
public void Release(string releasedBy)
{
EnsureStatus(ProductionStatus.Planned);
Raise(new ProductionOrderReleased(Id, releasedBy, DateTimeOffset.UtcNow));
}
public void Start(OperatorId operatorId)
{
if (Status is not (ProductionStatus.Planned or ProductionStatus.Released))
throw new ProductionDomainException(
"Chỉ bắt đầu sản xuất từ trạng thái Planned hoặc Released.",
"INVALID_STATUS_FOR_START");
Raise(new ProductionStarted(Id, operatorId, DateTimeOffset.UtcNow));
}
public void RecordOutput(Quantity qty)
{
EnsureStatus(ProductionStatus.InProgress);
var newTotal = ActualQty.Add(qty);
var maxAllowed = new Quantity(PlannedQty.Value * MaxOutputBuffer, PlannedQty.Unit);
if (newTotal.IsGreaterThan(maxAllowed))
throw new ProductionDomainException(
$"Tổng sản lượng ({newTotal}) vượt quá giới hạn cho phép ({maxAllowed}).",
"OUTPUT_EXCEEDS_LIMIT");
Raise(new ProductionOutputRecorded(Id, qty, DateTimeOffset.UtcNow));
}
public void Pause(string reason, string reasonCode)
{
EnsureStatus(ProductionStatus.InProgress);
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
Raise(new ProductionOrderPaused(Id, reason, reasonCode, DateTimeOffset.UtcNow));
}
public void Resume()
{
EnsureStatus(ProductionStatus.OnHold);
Raise(new ProductionOrderResumed(Id, DateTimeOffset.UtcNow));
}
public void Complete()
{
EnsureStatus(ProductionStatus.InProgress);
Raise(new ProductionOrderCompleted(Id, ActualQty, DateTimeOffset.UtcNow));
}
public void Cancel(string reason)
{
if (Status == ProductionStatus.Completed)
throw new ProductionDomainException(
"Không thể hủy lệnh đã hoàn thành.",
"CANNOT_CANCEL_COMPLETED");
if (Status == ProductionStatus.InProgress)
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
Raise(new ProductionOrderCancelled(Id, reason, DateTimeOffset.UtcNow));
}
// ── Apply (Event Sourcing) ────────────────────────────────────
protected override void Apply(DomainEvent @event)
{
switch (@event)
{
case ProductionOrderCreated e:
Id = e.OrderId;
OrderNumber = e.OrderNumber;
Product = e.Product;
PlannedQty = e.PlannedQty;
ActualQty = Quantity.Zero(e.PlannedQty.Unit);
MachineId = e.MachineId;
Shift = e.Shift;
TenantId = e.TenantId;
Status = ProductionStatus.Planned;
break;
case ProductionOrderReleased:
Status = ProductionStatus.Released;
break;
case ProductionStarted e:
Status = ProductionStatus.InProgress;
OperatorId = e.OperatorId;
break;
case ProductionOutputRecorded e:
ActualQty = ActualQty.Add(e.Quantity);
break;
case ProductionOrderPaused:
Status = ProductionStatus.OnHold;
break;
case ProductionOrderResumed:
Status = ProductionStatus.InProgress;
break;
case ProductionOrderCompleted:
Status = ProductionStatus.Completed;
break;
case ProductionOrderCancelled:
Status = ProductionStatus.Cancelled;
break;
}
}
// ── Guard ─────────────────────────────────────────────────────
private void EnsureStatus(ProductionStatus expected)
{
if (Status != expected)
throw new ProductionDomainException(
$"Lệnh đang ở trạng thái {Status}, không thể thực hiện khi không phải {expected}.",
"INVALID_STATUS");
}
}
Base Classes (Shared)
// ── AggregateRoot.cs ─────────────────────────────────────────────
namespace ERP.MES.Core.Shared;
public abstract class AggregateRoot<TId>
{
private readonly List<DomainEvent> _events = [];
public TId Id { get; protected set; } = default!;
public int Version { get; private set; }
public IReadOnlyList<DomainEvent> DomainEvents => _events.AsReadOnly();
protected void Raise(DomainEvent @event)
{
Apply(@event);
_events.Add(@event);
Version++;
}
protected abstract void Apply(DomainEvent @event);
public void ClearDomainEvents() => _events.Clear();
}
// ── DomainEvent.cs ───────────────────────────────────────────────
namespace ERP.MES.Core.Shared;
public abstract record DomainEvent(
Guid AggregateId,
DateTimeOffset OccurredAt)
{
public Guid EventId { get; } = Guid.NewGuid();
public string EventType { get; } = nameof(DomainEvent);
}
// ── IRepository.cs ───────────────────────────────────────────────
namespace ERP.MES.Core.Shared;
public interface IRepository<TAggregate, TId>
where TAggregate : AggregateRoot<TId>
{
Task<TAggregate?> GetByIdAsync(TId id, CancellationToken ct = default);
Task SaveAsync(TAggregate aggregate, CancellationToken ct = default);
}
CQRS Handlers — Commands & Queries
Command + Handler: StartProductionOrder
namespace ERP.MES.Application.Production.Commands;
using ERP.MES.Core.Production;
using ERP.MES.Core.Production.ValueObjects;
using ERP.MES.Core.Shared;
// ── Command (immutable record) ───────────────────────────────────
public sealed record StartProductionOrderCommand(
Guid OrderId,
Guid OperatorId,
string TenantId) : ICommand<StartProductionOrderResult>;
// ── Result ───────────────────────────────────────────────────────
public sealed record StartProductionOrderResult(
Guid OrderId,
string Status,
string OperatorId);
// ── Handler ──────────────────────────────────────────────────────
public sealed class StartProductionOrderHandler(
IProductionOrderRepository repository)
: ICommandHandler<StartProductionOrderCommand, StartProductionOrderResult>
{
public async Task<StartProductionOrderResult> HandleAsync(
StartProductionOrderCommand command,
CancellationToken ct)
{
var orderId = OrderId.From(command.OrderId);
var operatorId = OperatorId.From(command.OperatorId);
var order = await repository.GetByIdAsync(orderId, ct)
?? throw new NotFoundException($"Production Order {command.OrderId} không tìm thấy.");
// Business rule enforcement xảy ra bên trong aggregate
order.Start(operatorId);
await repository.SaveAsync(order, ct);
return new StartProductionOrderResult(
command.OrderId,
order.Status.ToString(),
command.OperatorId.ToString());
}
}
// ── Repository interface (domain layer) ─────────────────────────
public interface IProductionOrderRepository
: IRepository<ProductionOrder, OrderId>
{
Task<ProductionOrder?> GetByOrderNumberAsync(
OrderNumber number,
string tenantId,
CancellationToken ct = default);
}
Handler chỉ orchestrate: load → call domain method → save. Không có if/else business logic trong handler. Tất cả validation và business rules nằm trong ProductionOrder.Start(). Đây là điểm then chốt của DDD đúng nghĩa.