Production Bounded Context Direction B — Deep Dive .NET 9 · C#
Hoàn thành

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.

Cập nhật: 22/07/2026
Language: C# .NET 9
Pattern: DDD + CQRS + Event Sourcing
Path: src/Core/Production/
01 — Tổng quan

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_orders projection (read)
  • Các module phụ thuộc: Inventory (lock NVL), Quality (trigger inspection), Finance (tính chi phí)
Tại sao ProductionOrder là Aggregate Root?

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.

02 — Aggregate Design

State Machine — ProductionOrder

Sơ đồ 1 — Production Order State Machine
stateDiagram-v2 [*] --> Planned : CreateProductionOrder\n(Command) Planned --> Released : ReleaseOrder Released --> InProgress : StartProduction Planned --> InProgress : StartProduction\n(direct) InProgress --> OnHold : PauseProduction\n(machine breakdown / material shortage) OnHold --> InProgress : ResumeProduction InProgress --> Completed : CompleteOrder\n[actual_qty >= min_completion_qty] Planned --> Cancelled : CancelOrder Released --> Cancelled : CancelOrder InProgress --> Cancelled : CancelOrder\n[with reason] Completed --> [*] Cancelled --> [*] note right of InProgress RecordProductionOutput có thể gọi nhiều lần trong suốt quá trình end note
🖱️ Kéo để di chuyển · Cuộn chuột để zoom

Aggregate properties

PropertyTypeMô tảMutability
IdOrderIdIdentity duy nhấtImmutable
OrderNumberOrderNumberSố lệnh (PO-2026-001234)Immutable
ProductProductSpecSản phẩm sản xuấtImmutable
PlannedQtyQuantitySố lượng kế hoạchImmutable
ActualQtyQuantitySố lượng thực tế (tích lũy)Mutable
StatusProductionStatusTrạng thái hiện tạiMutable
MachineIdMachineIdMáy được giaoImmutable
OperatorIdOperatorId?Operator đang chạyMutable
ShiftShiftCa sản xuấtImmutable
TenantIdstringMulti-tenancyImmutable
VersionintOptimistic locking versionMutable
03 — Value Objects

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
record(Guid Value)
Identity của Production Order
Guid không được empty
OrderId.New() → gen UUID
OrderNumber
record(string Value)
Mã lệnh sản xuất có thể đọc được
Format: PO-YYYY-NNNNNN
Max 50 ký tự, uppercase
ProductSpec
record(Guid Id, string Code, string Name)
Thông tin sản phẩm tại thời điểm tạo lệnh (snapshot)
Id không empty
Code và Name không blank
Quantity
record(decimal Value, string Unit)
Số lượng + đơn vị đo. Hỗ trợ cộng, so sánh
Value > 0
Unit phải cùng khi cộng
Quantity.Zero(unit) allowed
ProductionStatus
enum
Trạng thái lệnh sản xuất
Planned, Released
InProgress, OnHold
Completed, Cancelled
MachineId / OperatorId
record(Guid Value)
Strongly typed IDs, tránh nhầm lẫn parameter
Không thể truyền MachineId vào slot OperatorId
04 — Business Rules

Business 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ệnLý do
R01 — Start từ đúng trạng tháiStatus phải là Planned hoặc ReleasedTránh restart lệnh đang chạy
R02 — Complete khi đang chạyStatus phải là InProgressLogic nhất quán
R03 — Số lượng dươngPlannedQty.Value > 0Lệnh phải có số lượng
R04 — Không vượt quá kế hoạchActualQty ≤ PlannedQty × 1.1Buffer 10% cho biến động
R05 — Ghi sản lượng khi chạyStatus phải là InProgressKhông ghi khi đang pause
R06 — Hủy chỉ khi chưa hoàn thànhStatus không phải CompletedLệnh đã hoàn thành không được hủy
R07 — Lý do hủy bắt buộcCancellationReason không blank khi hủy từ InProgressAudit trail
R08 — OrderNumber unique per tenantEnforce ở Application layer + DB unique constraintKhông trùng số lệnh
05 — Code Structure

Cấu trúc thư mục .NET 9

src/
├── 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)
06 — Domain Layer

Implementation — C# .NET 9

Value Objects

C# .NET 9 src/Core/Production/ValueObjects/ProductionOrderVOs.cs
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

C# .NET 9 src/Core/Production/Events/ProductionOrderEvents.cs
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

C# .NET 9 src/Core/Production/ProductionOrder.cs
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)

C# .NET 9 src/Core/Shared/AggregateRoot.cs + DomainEvent.cs
// ── 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);
}
07 — Application Layer

CQRS Handlers — Commands & Queries

Sơ đồ 2 — Command Flow (StartProductionOrder)
sequenceDiagram participant API as API Controller participant BUS as Command Bus (MediatR) participant H as StartProductionOrderHandler participant REPO as IProductionOrderRepository participant ES as Event Store (PostgreSQL) participant OB as Outbox Table participant PUB as Kafka Publisher API->>BUS: StartProductionOrderCommand BUS->>H: Handle(command, ct) H->>REPO: GetByIdAsync(orderId) REPO->>ES: Load events by aggregate_id ES-->>REPO: Domain Events[] REPO-->>H: ProductionOrder (reconstituted) H->>H: order.Start(operatorId) Note over H: Validate business rules\nRaise ProductionStarted event H->>REPO: SaveAsync(order) REPO->>ES: INSERT new events (optimistic lock) REPO->>OB: INSERT to outbox (same transaction) ES-->>H: OK H-->>API: StartProductionOrderResult Note over PUB: Background: poll outbox\npublish to Kafka
🖱️ Kéo để di chuyển · Cuộn chuột để zoom

Command + Handler: StartProductionOrder

C# .NET 9 src/Application/Production/Commands/StartProductionOrder.cs
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);
}
Domain logic nằm hoàn toàn trong Aggregate

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.

Source code đã tạo tại

src/Core/ và src/Application/Production/

← Về trang chủ