انتقل إلى المحتوى الرئيسي

Unified Transaction Aggregate with Type-Specific Details via Composition

Architectural Decision Record: Unified Transaction Aggregate with Type Discriminator

1. Executive Summary

This document formalizes the architectural decision to use a single unified transaction aggregate for all inventory transaction types (Purchase, Sales, Movement, Adjustment, Assembly), rather than creating separate aggregates or using inheritance hierarchies for each type.

The transaction type is determined by an enum discriminator (TransactionType), and type-specific data is stored in optional value objects using composition (MovementDetails, AdjustmentDetails, AssemblyDetails, SalesDetails, PurchaseDetails).

This decision provides:

  • Single immutable transaction model for complete audit trail
  • Simplified querying across all transaction types
  • Type-safe enforcement of business rules via factory methods
  • Extensible pattern for new transaction types
  • Clean persistence model without complex inheritance mapping

This approach follows the same composition-based philosophy established in ADR-ITEM-001 and aligns with our overall architectural strategy.

2. Context: Transaction Type Complexity

The Inventory Management Module must support five distinct transaction types, each with unique characteristics:

Transaction TypePurposeKey Attributes
PurchaseReceive goods from vendorsVendor, PO number, invoice, receiving location
SalesShip goods to customersCustomer, SO number, invoice, shipping location
MovementTransfer between locationsSource location, destination location, reason
AdjustmentCorrect discrepanciesAdjustment type (±), location, reason
AssemblyManufacture finished goodsBOM, produced item, assembly location, components

Common Attributes (All Types):

  • Transaction date
  • Line items (item, quantity, unit of measure)
  • Reference numbers
  • Notes
  • Audit fields (created date, user, etc.)
  • Immutability after creation

The architectural challenge is balancing type-specific requirements with code reuse and query flexibility.

3. The Architectural Debate: Alternative Approaches

3.1. Separate Aggregates per Type (Rejected)

public class PurchaseTransaction : AggregateRoot { /* ... */ }
public class SalesTransaction : AggregateRoot { /* ... */ }
public class MovementTransaction : AggregateRoot { /* ... */ }
public class AdjustmentTransaction : AggregateRoot { /* ... */ }
public class AssemblyTransaction : AggregateRoot { /* ... */ }

Arguments For:

  • Maximum type safety at compile time
  • Each aggregate has only relevant properties
  • Explicit domain models for each business process

Arguments Against (Why Rejected):

  • No unified transaction history
  • Complex queries to get "all transactions" (requires unions)
  • Duplicate code for common transaction logic
  • Difficult to implement cross-type features (reporting, search)
  • Five separate repositories needed

3.2. Inheritance Hierarchy (Rejected)

public abstract class InventoryTransactionBase : AggregateRoot
{
public DateTime TransactionDate { get; protected set; }
public IReadOnlyList<TransactionLineItem> Lines { get; protected set; }
}

public class PurchaseTransaction : InventoryTransactionBase
{
public Guid VendorId { get; private set; }
public string PurchaseOrderNumber { get; private set; }
}

public class MovementTransaction : InventoryTransactionBase
{
public Guid SourceLocationId { get; private set; }
public Guid DestinationLocationId { get; private set; }
}

Arguments For:

  • Polymorphism allows treating all transactions uniformly
  • Type-specific properties organized in subclasses
  • Familiar OOP pattern

Arguments Against (Why Rejected):

  • Complex EF Core mapping (TPH/TPT/TPC tradeoffs)
  • Base class bloats with properties for all types
  • Rigidity: Cannot change transaction type after creation
  • Query complexity with discriminators
  • Follows rejected pattern from ADR-ITEM-001

3.3. Unified Aggregate with Discriminator and Composition (Accepted)

public class InventoryTransaction : AggregateRoot
{
public DateTime TransactionDate { get; private set; }
public TransactionType Type { get; private set; }
public IReadOnlyList<TransactionLineItem> Lines { get; private set; }

// Type-specific details via composition
public MovementDetails? MovementDetails { get; private set; }
public AdjustmentDetails? AdjustmentDetails { get; private set; }
public AssemblyDetails? AssemblyDetails { get; private set; }
public SalesDetails? SalesDetails { get; private set; }
public PurchaseDetails? PurchaseDetails { get; private set; }

// Factory methods enforce type-specific rules
public static InventoryTransaction CreateMovement(/* params */) { /* ... */ }
public static InventoryTransaction CreateAdjustment(/* params */) { /* ... */ }
public static InventoryTransaction CreateAssembly(/* params */) { /* ... */ }
public static InventoryTransaction CreateSales(/* params */) { /* ... */ }
public static InventoryTransaction CreatePurchase(/* params */) { /* ... */ }
}

public enum TransactionType
{
Purchase = 1,
Sales = 2,
Movement = 3,
Adjustment = 4,
Assembly = 5
}

// Value objects for type-specific data
public class MovementDetails : ValueObject
{
public Guid SourceLocationId { get; private set; }
public Guid DestinationLocationId { get; private set; }
public string Reason { get; private set; }
}

public class AssemblyDetails : ValueObject
{
public Guid BomId { get; private set; }
public Guid ProducedItemId { get; private set; }
public decimal ProducedQuantity { get; private set; }
public Guid AssemblyLocationId { get; private set; }
}

Arguments For (Why Accepted):

  • Single aggregate simplifies transaction history queries
  • Type discriminator enables efficient filtering
  • Value objects cleanly encapsulate type-specific concerns
  • Factory methods enforce business rules at creation
  • EF Core owned entities handle composition naturally
  • Immutability prevents type confusion after creation
  • Extensible for new transaction types

4. The Accepted Solution: Unified Transaction with Composition

4.1. Core Design Principles

Principle 1: Single Source of Truth

  • One InventoryTransaction aggregate represents all transaction types
  • TransactionType enum is the canonical discriminator
  • Enables unified transaction history and audit trail

Principle 2: Composition for Type-Specific Data

  • Nullable value objects hold type-specific details
  • Only one details object should be non-null (enforced by factory methods)
  • Immutable value objects prevent post-creation modification

Principle 3: Factory Methods Enforce Invariants

  • Private constructor prevents direct instantiation
  • Public static factory methods per transaction type
  • Each factory validates type-specific business rules
  • Ensures type and details are consistent at creation

Principle 4: Immutability After Creation

  • No public setters on transaction properties
  • Cannot change type or details after creation
  • Corrections require new transactions (adjustment pattern)
  • Guarantees audit trail integrity

4.2. Architectural Diagram

4.3. Factory Method Pattern

Each transaction type has a dedicated factory method that enforces business rules:

Movement Transaction:

public static InventoryTransaction CreateMovement(
DateTime transactionDate,
Guid sourceLocationId,
Guid destinationLocationId,
string reason,
IEnumerable<TransactionLineItem> lines)
{
// Validate business rules
if (sourceLocationId == destinationLocationId)
throw new DomainException("Source and destination locations cannot be the same");

if (string.IsNullOrWhiteSpace(reason))
throw new DomainException("Movement reason is required");

if (!lines.Any())
throw new DomainException("At least one line item is required");

// Create transaction with movement details
var transaction = new InventoryTransaction
{
Id = Guid.NewGuid(),
TransactionDate = transactionDate,
Type = TransactionType.Movement,
MovementDetails = new MovementDetails
{
SourceLocationId = sourceLocationId,
DestinationLocationId = destinationLocationId,
Reason = reason
},
Lines = lines.ToList()
};

transaction.AddDomainEvent(new MovementTransactionCreatedEvent(transaction.Id));
return transaction;
}

Assembly Transaction:

public static InventoryTransaction CreateAssembly(
DateTime transactionDate,
Guid bomId,
Guid producedItemId,
decimal producedQuantity,
Guid assemblyLocationId,
BillOfMaterial bom) // Passed in for validation
{
// Validate business rules
if (producedQuantity <= 0)
throw new DomainException("Produced quantity must be positive");

if (bom.ParentItemId != producedItemId)
throw new DomainException("BOM does not match produced item");

// Explode BOM to get component requirements
var requiredComponents = bom.Explode(producedQuantity);

// Create line items for component consumption (negative quantities)
var componentLines = requiredComponents.Select(c => new TransactionLineItem
{
ItemId = c.ComponentItemId,
Quantity = -c.Quantity, // Negative = consumption
UnitOfMeasureId = c.UnitOfMeasureId
});

// Create line item for produced goods (positive quantity)
var producedLine = new TransactionLineItem
{
ItemId = producedItemId,
Quantity = producedQuantity, // Positive = production
UnitOfMeasureId = bom.ProducedUnitOfMeasureId
};

var allLines = componentLines.Append(producedLine).ToList();

var transaction = new InventoryTransaction
{
Id = Guid.NewGuid(),
TransactionDate = transactionDate,
Type = TransactionType.Assembly,
AssemblyDetails = new AssemblyDetails
{
BomId = bomId,
ProducedItemId = producedItemId,
ProducedQuantity = producedQuantity,
AssemblyLocationId = assemblyLocationId
},
Lines = allLines
};

transaction.AddDomainEvent(new AssemblyTransactionCreatedEvent(transaction.Id));
return transaction;
}

5. Implementation Guidance

5.1. Entity Framework Core Configuration

public class InventoryTransactionConfiguration : IEntityTypeConfiguration<InventoryTransaction>
{
public void Configure(EntityTypeBuilder<InventoryTransaction> builder)
{
builder.ToTable("inventory_transactions");

builder.HasKey(t => t.Id);

builder.Property(t => t.Type)
.HasConversion<int>()
.IsRequired();

// Owned entity for MovementDetails
builder.OwnsOne(t => t.MovementDetails, md =>
{
md.Property(d => d.SourceLocationId).HasColumnName("movement_source_location_id");
md.Property(d => d.DestinationLocationId).HasColumnName("movement_destination_location_id");
md.Property(d => d.Reason).HasColumnName("movement_reason").HasMaxLength(500);
});

// Owned entity for AssemblyDetails
builder.OwnsOne(t => t.AssemblyDetails, ad =>
{
ad.Property(d => d.BomId).HasColumnName("assembly_bom_id");
ad.Property(d => d.ProducedItemId).HasColumnName("assembly_produced_item_id");
ad.Property(d => d.ProducedQuantity).HasColumnName("assembly_produced_quantity");
ad.Property(d => d.AssemblyLocationId).HasColumnName("assembly_location_id");
});

// Similar for AdjustmentDetails, SalesDetails, PurchaseDetails...

// Line items as owned collection
builder.OwnsMany(t => t.Lines, line =>
{
line.ToTable("inventory_transaction_lines");
line.WithOwner().HasForeignKey("transaction_id");
line.Property<Guid>("Id").HasColumnName("id");
line.HasKey("Id");
line.Property(l => l.ItemId).HasColumnName("item_id");
line.Property(l => l.Quantity).HasColumnName("quantity");
line.Property(l => l.UnitOfMeasureId).HasColumnName("unit_of_measure_id");
});
}
}

Database Schema:

CREATE TABLE inventory_transactions (
id UUID PRIMARY KEY,
transaction_date TIMESTAMP NOT NULL,
type INTEGER NOT NULL, -- Enum discriminator

-- Movement details (nullable)
movement_source_location_id UUID NULL,
movement_destination_location_id UUID NULL,
movement_reason VARCHAR(500) NULL,

-- Assembly details (nullable)
assembly_bom_id UUID NULL,
assembly_produced_item_id UUID NULL,
assembly_produced_quantity DECIMAL(18,6) NULL,
assembly_location_id UUID NULL,

-- Adjustment details (nullable)
adjustment_type INTEGER NULL,
adjustment_location_id UUID NULL,
adjustment_reason VARCHAR(500) NULL,

-- Sales details (nullable)
sales_customer_id UUID NULL,
sales_order_number VARCHAR(50) NULL,
sales_invoice_number VARCHAR(50) NULL,

-- Purchase details (nullable)
purchase_vendor_id UUID NULL,
purchase_order_number VARCHAR(50) NULL,
purchase_invoice_number VARCHAR(50) NULL,

created_date TIMESTAMP NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true
);

CREATE TABLE inventory_transaction_lines (
id UUID PRIMARY KEY,
transaction_id UUID NOT NULL REFERENCES inventory_transactions(id),
item_id UUID NOT NULL,
quantity DECIMAL(18,6) NOT NULL,
unit_of_measure_id UUID NOT NULL,
notes VARCHAR(1000) NULL
);

CREATE INDEX idx_transactions_type ON inventory_transactions(type);
CREATE INDEX idx_transactions_date ON inventory_transactions(transaction_date);
CREATE INDEX idx_transaction_lines_item ON inventory_transaction_lines(item_id);

5.2. Query Patterns

All Transactions of Specific Type:

var movements = await _repository
.GetAllAsync()
.Where(t => t.Type == TransactionType.Movement)
.ToListAsync();

Transactions for Date Range:

var transactions = await _repository
.GetAllAsync()
.Where(t => t.TransactionDate >= startDate && t.TransactionDate <= endDate)
.OrderByDescending(t => t.TransactionDate)
.ToListAsync();

Transactions Involving Specific Item:

var itemTransactions = await _repository
.GetAllAsync()
.Where(t => t.Lines.Any(l => l.ItemId == itemId))
.Include(t => t.Lines)
.ToListAsync();

Assembly Transactions for Specific BOM:

var assemblyTxns = await _repository
.GetAllAsync()
.Where(t => t.Type == TransactionType.Assembly &&
t.AssemblyDetails != null &&
t.AssemblyDetails.BomId == bomId)
.ToListAsync();

5.3. Application Layer Commands

public class CreateMovementCommand : IRequest<TransactionDto>
{
public DateTime TransactionDate { get; set; }
public Guid SourceLocationId { get; set; }
public Guid DestinationLocationId { get; set; }
public string Reason { get; set; }
public List<TransactionLineItemDto> LineItems { get; set; }
}

public class CreateMovementCommandHandler : IRequestHandler<CreateMovementCommand, TransactionDto>
{
private readonly IRepository<InventoryTransaction> _repository;
private readonly IUnitOfWork _unitOfWork;

public async Task<TransactionDto> Handle(CreateMovementCommand request, CancellationToken cancellationToken)
{
// Validate locations exist and are operational
// Validate items exist and are stockable
// Validate sufficient inventory at source location

var lineItems = request.LineItems.Select(dto => new TransactionLineItem
{
ItemId = dto.ItemId,
Quantity = dto.Quantity,
UnitOfMeasureId = dto.UnitOfMeasureId,
Notes = dto.Notes
});

var transaction = InventoryTransaction.CreateMovement(
request.TransactionDate,
request.SourceLocationId,
request.DestinationLocationId,
request.Reason,
lineItems
);

await _repository.AddAsync(transaction);
await _unitOfWork.CommitAsync();

return _mapper.Map<TransactionDto>(transaction);
}
}

6. Benefits and Trade-offs

6.1. Benefits

Unified Transaction History:

  • Single query gets all transactions regardless of type
  • Easy to generate comprehensive audit reports
  • Simplified transaction search and filtering

Type Safety via Factory Methods:

  • Cannot create invalid transaction (e.g., movement with sales details)
  • Business rules enforced at creation time
  • Compile-time prevention of constructor misuse

Query Performance:

  • Single table or simple owned entity joins
  • Efficient filtering by type with index on enum column
  • No complex discriminator mapping or table unions

Extensibility:

  • New transaction types added by extending enum and adding value object
  • No changes to existing transactions required
  • Pattern established for future types

Immutability:

  • Transactions cannot be modified after creation
  • Complete audit trail integrity
  • Corrections handled by creating new transactions

6.2. Trade-offs

Nullable Details Properties:

  • Must check which details object is non-null
  • Mitigation: Type discriminator makes this explicit, factory methods guarantee consistency

Schema Sparsity:

  • Many NULL columns in database for type-specific fields
  • Mitigation: Modern databases handle sparse columns efficiently, alternative is JSON column (future option)

Factory Method Complexity:

  • Each type needs dedicated factory with validation
  • Mitigation: Centralized business logic, better than scattered validation

7. Invariant Enforcement

Critical Invariant: Exactly one details object must be non-null, matching the transaction type.

Enforcement Mechanisms:

  1. Private constructor prevents direct instantiation
  2. Factory methods set type and corresponding details atomically
  3. No public setters allow post-creation modification
  4. Unit tests verify invariant for all factories

Validation Method (for defensive programming):

private void ValidateTypeDetailsConsistency()
{
var nonNullCount = new[]
{
MovementDetails != null,
AdjustmentDetails != null,
AssemblyDetails != null,
SalesDetails != null,
PurchaseDetails != null
}.Count(x => x);

if (nonNullCount != 1)
throw new DomainException("Transaction must have exactly one type-specific details object");

// Verify details match type
var detailsMatchType = Type switch
{
TransactionType.Movement => MovementDetails != null,
TransactionType.Adjustment => AdjustmentDetails != null,
TransactionType.Assembly => AssemblyDetails != null,
TransactionType.Sales => SalesDetails != null,
TransactionType.Purchase => PurchaseDetails != null,
_ => false
};

if (!detailsMatchType)
throw new DomainException($"Transaction type {Type} does not match details object");
}

8. Future Enhancements

Additional Transaction Types:

  • Transfer Orders (between legal entities)
  • Consignment Receipts/Returns
  • RMA (Return Merchandise Authorization)
  • Scrap/Waste transactions

Each added by:

  1. Adding enum value to TransactionType
  2. Creating new details value object
  3. Adding property to InventoryTransaction
  4. Creating factory method with business rules
  5. Adding EF Core owned entity configuration

JSON Column Option (Performance Optimization): For systems with many transaction types, consider storing all details in a single JSON column:

public string DetailsJson { get; private set; }

// Deserialize based on Type enum
public T GetDetails<T>() where T : class
{
return JsonSerializer.Deserialize<T>(DetailsJson);
}

9. Final Decision

The Unified Transaction Aggregate with Type Discriminator and Compositional Details provides the optimal architecture for the Inventory Management Module's transaction processing. It balances type safety, query flexibility, and extensibility while maintaining transaction immutability and audit integrity.

The team is directed to implement all transaction types using this unified aggregate pattern. All existing and future transaction types must use factory methods to enforce business rules and maintain type-details consistency.

Status: Accepted Implementation Start: October 2024 Review Date: April 2025 (6-month retrospective)