Skip to main content

InventoryTransaction

File: src/IM.Domain/Aggregates/TransactionAggregate/Entities/InventoryTransaction.cs Module: Inventory Namespace: IM.Domain.Aggregates.TransactionAggregate.Entities

Purpose

The InventoryTransaction aggregate is the core domain entity responsible for recording all inventory movements and changes across the entire inventory system. It serves as the single source of truth for inventory audit trails, enforcing business rules while maintaining complete immutability for compliance and historical accuracy.

This aggregate implements a composition-based architecture where a single base transaction entity contains type-specific details (MovementDetails, AdjustmentDetails, etc.), eliminating the need for complex inheritance hierarchies and enabling flexible extensibility.

Business Context

Why Immutable Transactions?

Inventory transactions represent financial events with legal and compliance implications:

  • Audit Trail Requirements: Complete, unchangeable history for financial audits
  • Regulatory Compliance: SOX, GAAP, and other standards require immutable records
  • Historical Accuracy: Past transactions must reflect what actually happened
  • Financial Integrity: Changes to inventory affect Cost of Goods Sold (COGS) and valuation

Correction Mechanism: Errors are corrected through new adjustment transactions, not by modifying existing records. This maintains a complete audit trail showing both the original error and the correction.

Transaction Lifecycle

Key Points:

  • No "pending" or "draft" state - transactions are created fully valid
  • No "update" or "delete" operations - transactions are permanent
  • Corrections require new transactions (adjustment type)

Business Rules & Invariants

Core Business Rules

  1. Immutability: Once created, transactions cannot be modified or deleted.

    • Rationale: Audit trail integrity, compliance requirements
    • Enforcement: Private constructor, factory methods only, no update methods
  2. Type-Specific Validation: Each transaction type has unique validation rules.

    • Movement: Source ≠ Destination
    • Adjustment: NewQuantity ≠ CurrentQuantity
    • Assembly: Valid BOM, sufficient components
    • Sale/Purchase: Valid customer/vendor, location
  3. Positive Line Item Quantities: All line item quantities must be positive.

    • Rationale: Direction determined by transaction type, not quantity sign
    • Enforcement: Line item validation in AddLineItems()
    • Example: Adjustment quantity of -10 becomes line item quantity of 10 with IsPositive=false flag
  4. No Duplicate Items: Each item can appear only once per transaction.

    • Rationale: Simplifies processing, prevents errors
    • Enforcement: Duplicate detection in AddLineItems()
    • Solution: Combine quantities if same item needed
  5. Required Audit Fields: All transactions must have UserId, Timestamp, and Reason.

    • Rationale: Complete audit trail, regulatory compliance
    • Enforcement: Required parameters in factory methods
  6. External Reference Tracking: Optional link to external documents.

    • Purpose: Connect to purchase orders, sales invoices, etc.
    • Format: Free text, typically PO numbers, invoice numbers

State Management Rules

No State Transitions: Transactions don't have state - they're created and permanent.

Future Consideration:

  • Return/reversal transactions (new transaction type)
  • Voided transactions (new field or separate aggregate)

Transaction Types

Overview

TypeDirectionPurposeDetails ObjectCreated By
Purchase↑ IncreasesReceive from vendorsPurchaseDetailsFinance integration
Sale↓ DecreasesShip to customersSalesDetailsFinance integration
Movement⇄ NeutralTransfer between locationsMovementDetailsInventory users
Adjustment↕ BothCorrect discrepanciesAdjustmentDetailsInventory users
Assembly↕ BothManufacture finished goodsAssemblyDetailsProduction users

Purchase Transaction

Purpose: Record receipt of inventory from vendors (increases inventory).

Typical Scenario:

  1. Finance module posts purchase invoice
  2. Integration event triggers inventory consumer
  3. Inventory creates Purchase transaction
  4. Inventory quantities increased at receiving location

Factory Method:

public static InventoryTransaction CreatePurchaseTransaction(
Guid userId,
Guid vendorId,
Guid receivingLocationId,
Guid? invoiceId,
string invoiceNumber,
IEnumerable<LineItemData> lineItemData,
string reason,
string? externalReference = null,
string? description = null)

Validation Rules:

  • Vendor ID must be valid (application layer)
  • Receiving location must exist and be operational
  • All items must be stockable
  • Line items required

Type-Specific Details: PurchaseDetails

public class PurchaseDetails
{
public Guid VendorId { get; private set; }
public Guid ReceivingLocationId { get; private set; }
public Guid? InvoiceId { get; private set; }
public string InvoiceNumber { get; private set; }
public string? Description { get; private set; }
}

Integration: Created from PurchaseInvoiceInventoryPostingRequestedIntegrationEvent

Documentation: purchase-details.md


Sale Transaction

Purpose: Record shipment of inventory to customers (decreases inventory).

Typical Scenario:

  1. Finance module posts sales invoice
  2. Integration event triggers inventory consumer
  3. Inventory creates Sale transaction
  4. Inventory quantities decreased at shipping location

Factory Method:

public static InventoryTransaction CreateSalesTransaction(
Guid userId,
Guid customerId,
Guid shippingLocationId,
Guid? invoiceId,
string invoiceNumber,
IEnumerable<LineItemData> lineItemData,
string reason,
string? externalReference = null,
string? description = null)

Validation Rules:

  • Customer ID must be valid (application layer)
  • Shipping location must exist and be operational
  • All items must be stockable
  • Sufficient inventory available (optional - depends on AllowNegativeStock)

Type-Specific Details: SalesDetails

public class SalesDetails
{
public Guid CustomerId { get; private set; }
public Guid ShippingLocationId { get; private set; }
public Guid? InvoiceId { get; private set; }
public string InvoiceNumber { get; private set; }
public string? Description { get; private set; }
}

Integration: Created from SalesInvoiceInventoryPostingRequestedIntegrationEvent

Documentation: sales-details.md


Movement Transaction

Purpose: Transfer inventory between locations (neutral on totals).

Typical Scenario:

  1. User initiates stock movement
  2. Application creates Movement transaction
  3. Source location inventory decreased
  4. Destination location inventory increased

Factory Method:

public static InventoryTransaction CreateMovement(
Guid userId,
Guid sourceLocationId,
Guid destinationLocationId,
IEnumerable<LineItemData> lineItemData,
string reason,
string? externalReference = null,
string? movementReference = null,
string? notes = null)

Validation Rules:

  • Source location must exist and be operational
  • Destination location must exist and be operational
  • Source ≠ Destination (business rule)
  • All items must exist and be stockable
  • Line items required

Type-Specific Details: MovementDetails

public class MovementDetails
{
public Guid SourceLocationId { get; private set; }
public Guid DestinationLocationId { get; private set; }
public string? MovementReference { get; private set; }
public string? Notes { get; private set; }

// Validation
private static void ValidateMovement(Guid sourceLocationId, Guid destinationLocationId)
{
if (sourceLocationId == destinationLocationId)
throw new DomainException("Source and destination cannot be the same");
}
}

Business Use Cases:

  • Warehouse-to-warehouse transfers
  • Production area replenishment
  • Customer returns to stock
  • Inter-department transfers

Documentation: movement-details.md


Adjustment Transaction

Purpose: Correct inventory discrepancies (can increase or decrease).

Typical Scenario:

  1. Cycle count reveals discrepancy
  2. User initiates adjustment with current and new quantities
  3. System calculates adjustment quantity and direction
  4. Single-item transaction created
  5. Inventory updated to new quantity

Factory Method:

public static InventoryTransaction CreateAdjustment(
Guid userId,
Guid locationId,
decimal currentQuantity,
decimal newQuantity,
Guid itemId,
Guid unitOfMeasureId,
string reason,
string? externalReference = null,
string? notes = null)

Domain Logic:

// DOMAIN CALCULATES: Direction and absolute quantity
decimal adjustmentQuantity = newQuantity - currentQuantity;
bool isPositiveAdjustment = adjustmentQuantity > 0;

// Example: Current=100, New=95 → Adjustment=-5, IsPositive=false
// Line item quantity = Math.Abs(-5) = 5 (always positive)

Validation Rules:

  • Location must exist and be operational
  • CurrentQuantity ≥ 0
  • NewQuantity ≥ 0 (negative adjustments use NewQuantity=0)
  • NewQuantity ≠ CurrentQuantity (no zero adjustments)
  • Exactly one line item (adjustments are single-item)

Type-Specific Details: AdjustmentDetails

public class AdjustmentDetails
{
public Guid LocationId { get; private set; }
public bool IsPositiveAdjustment { get; private set; }

// Calculated from current vs new quantity
public bool IsIncrease => IsPositiveAdjustment;
public bool IsDecrease => !IsPositiveAdjustment;
}

Business Use Cases:

  • Cycle count corrections
  • Damage/loss adjustments
  • Found inventory
  • System error corrections
  • Shrinkage/theft recording

Documentation: adjustment-details.md


Assembly Transaction

Purpose: Manufacture finished goods from component items (both directions).

Typical Scenario:

  1. Production order created for finished good
  2. System checks BOM feasibility (enough components)
  3. User initiates assembly operation
  4. Components consumed (decreased inventory)
  5. Finished good produced (increased inventory)

Factory Method:

public static InventoryTransaction CreateAssembly(
Guid userId,
Guid assemblyItemId,
decimal quantityProduced,
Guid unitOfMeasureId,
Guid assemblyLocationId,
Guid billOfMaterialId,
IEnumerable<LineItemData> consumedComponents,
string reason,
string? externalReference = null)

Validation Rules:

  • Assembly item must exist and be stockable
  • Quantity produced > 0
  • Assembly location must exist
  • BOM must exist and match assembly item
  • All component items must be in line items
  • Component quantities must match BOM × quantity produced
  • Sufficient component inventory available (application layer)

Type-Specific Details: AssemblyDetails

public class AssemblyDetails
{
public Guid BillOfMaterialId { get; private set; }
public Guid ProducedItemId { get; private set; }
public decimal QuantityProduced { get; private set; }
public Guid ProducedUnitOfMeasureId { get; private set; }
public Guid AssemblyLocationId { get; private set; }
}

Line Items:

  • Consumed Components: All components from BOM (quantity = BOM qty × production qty)
  • Produced Item: NOT in line items (tracked in AssemblyDetails)

Inventory Effects:

Component A: -10 units (consumed)
Component B: -5 units (consumed)
Finished Good: +1 unit (produced via AssemblyDetails)

Business Use Cases:

  • Manufacturing operations
  • Assembly work orders
  • Kit creation
  • Production runs
  • Rework operations (future)

Documentation: assembly-details.md


Key Domain Methods

Factory Methods

Design Pattern: Static factory methods with private constructor

Why Factory Methods?

  • Enforce business rules before creation
  • Impossible to create invalid transactions
  • Clear, intention-revealing API
  • Type-safe construction

Common Pattern:

// 1. Private constructor prevents direct instantiation
private InventoryTransaction(
TransactionType type,
Guid userId,
DateTime timestamp,
string reason,
string? externalReference = null)
{
Id = Guid.NewGuid();
Type = type;
UserId = userId;
Timestamp = timestamp;
Reason = reason;
ExternalReference = externalReference;
}

// 2. Public factory method enforces rules
public static InventoryTransaction CreateMovement(...)
{
// a. Validate common rules
ValidateCommonTransactionRules(userId, reason, lineItemData);

// b. Validate type-specific rules
ValidateMovementSpecificRules(sourceLocationId, destinationLocationId);

// c. Create transaction
var transaction = new InventoryTransaction(
TransactionType.Movement,
userId,
DateTime.UtcNow,
reason.Trim(),
externalReference?.Trim());

// d. Set type-specific details
transaction.MovementDetails = MovementDetails.Create(...);

// e. Create and add line items
var lineItems = materializedLineData.Select(...).ToList();
transaction.AddLineItems(lineItems);

return transaction;
}

Line Item Management

AddLineItems (Private)

Purpose: Adds validated line items to transaction

private void AddLineItems(IEnumerable<TransactionLineItem> lineItems)
{
var itemsToAdd = lineItems.ToList();

// Business rule: Must have at least one line item
if (!itemsToAdd.Any())
throw new DomainException("Transaction must contain at least one line item");

// Business rule: No duplicate items
var hasDuplicates = itemsToAdd.GroupBy(li => li.ItemId).Any(g => g.Count() > 1);
if (hasDuplicates)
throw new DomainException("Transaction cannot contain duplicate items. Combine quantities for each item.");

// Business rule: All quantities must be positive
if (itemsToAdd.Any(li => li.Quantity <= 0))
throw new DomainException("All line item quantities must be positive");

_lineItems.AddRange(itemsToAdd);
}

GetLineItemForItem

Purpose: Retrieves line item for specific item

public TransactionLineItem? GetLineItemForItem(Guid itemId)
{
return _lineItems.FirstOrDefault(li => li.ItemId == itemId);
}

Use Case: Inventory event handlers lookup quantity for each item

GetQuantityForItem

Purpose: Gets transacted quantity for specific item

public decimal GetQuantityForItem(Guid itemId)
{
return GetLineItemForItem(itemId)?.Quantity ?? 0;
}

Validation Methods

ValidateCommonTransactionRules

Purpose: Validates rules common to all transaction types

private static void ValidateCommonTransactionRules(
Guid userId,
string reason,
IEnumerable<LineItemData> lineItemData)
{
if (userId == Guid.Empty)
throw new DomainException("User ID is required for transaction audit trail");

if (string.IsNullOrWhiteSpace(reason))
throw new DomainException("Business reason is required for all transactions");

if (reason.Length > 500)
throw new DomainException("Transaction reason cannot exceed 500 characters");

if (lineItemData == null)
throw new DomainException("Line items collection cannot be null");
}

ValidateMovementSpecificRules

Purpose: Validates movement-specific business rules

private static void ValidateMovementSpecificRules(Guid sourceLocationId, Guid destinationLocationId)
{
if (sourceLocationId == Guid.Empty)
throw new DomainException("Source location ID is required for movement transactions");

if (destinationLocationId == Guid.Empty)
throw new DomainException("Destination location ID is required for movement transactions");

// CRITICAL BUSINESS RULE
if (sourceLocationId == destinationLocationId)
throw new DomainException("Source and destination locations cannot be the same");
}

ValidateAdjustmentSpecificRules

Purpose: Validates adjustment-specific rules

private static void ValidateAdjustmentSpecificRules(
Guid locationId,
decimal currentQuantity,
decimal newQuantity)
{
if (locationId == Guid.Empty)
throw new DomainException("Location ID is required for adjustment transactions");

if (currentQuantity < 0)
throw new DomainException("Current quantity cannot be negative");

if (newQuantity < 0)
throw new DomainException("New quantity cannot be negative for adjustments");

// Handled in factory method:
// if (newQuantity == currentQuantity)
// throw new DomainException("New quantity same as current - no adjustment needed");
}

ValidateAssemblySpecificRules

Purpose: Validates assembly-specific rules

private static void ValidateAssemblySpecificRules(
Guid assemblyItemId,
decimal quantityProduced,
Guid unitOfMeasureId,
Guid assemblyLocationId,
Guid billOfMaterialId)
{
if (assemblyItemId == Guid.Empty)
throw new DomainException("Assembled Item ID is required.");

if (quantityProduced <= 0)
throw new DomainException("Quantity produced must be positive.");

if (unitOfMeasureId == Guid.Empty)
throw new DomainException("Unit of Measure for production is required.");

if (assemblyLocationId == Guid.Empty)
throw new DomainException("Assembly location is required.");

if (billOfMaterialId == Guid.Empty)
throw new DomainException("Bill of Material ID is required for assembly transactions.");
}

Audit and Display Methods

GetTransactionDescription

Purpose: Human-readable transaction summary

public string GetTransactionDescription()
{
var description = $"{Type.Name} transaction with {ItemCount} item(s)";

if (Type == TransactionType.Movement && MovementDetails != null)
{
description += $" from {MovementDetails.SourceLocationId:N} to {MovementDetails.DestinationLocationId:N}";
}

if (!string.IsNullOrEmpty(ExternalReference))
{
description += $" (Ref: {ExternalReference})";
}

return description;
}

Output Example: "Movement transaction with 3 item(s) from WAREHOUSE-01 to WAREHOUSE-02 (Ref: TRANSFER-2024-001)"

GetTotalQuantity

Purpose: Sum of all line item quantities

public decimal GetTotalQuantity()
{
return _lineItems.Sum(li => li.Quantity);
}

Use Case: Reporting, validation, display


Value Objects

TransactionLineItem

Purpose: Individual item-quantity-unit combination within a transaction

public class TransactionLineItem : ValueObject
{
public Guid TransactionId { get; private set; }
public Guid ItemId { get; private set; }
public decimal Quantity { get; private set; }
public Guid UnitOfMeasureId { get; private set; }
public string? Notes { get; private set; }

// Business constraints
public const decimal MaxQuantity = 999_999_999m;
public const int MaxDecimalPlaces = 4;

public static TransactionLineItem Create(
Guid transactionId,
Guid itemId,
decimal quantity,
Guid unitOfMeasureId,
string? notes = null)
{
ValidateQuantity(quantity);
ValidateIds(transactionId, itemId, unitOfMeasureId);

return new TransactionLineItem
{
TransactionId = transactionId,
ItemId = itemId,
Quantity = quantity,
UnitOfMeasureId = unitOfMeasureId,
Notes = notes?.Trim()
};
}
}

Immutability: Value object - no setters, cannot be modified


LineItemData (Parameter Object)

Purpose: Clean contract for factory methods (NOT persisted)

public record LineItemData(
Guid ItemId,
decimal Quantity,
Guid UnitOfMeasureId,
string? Notes = null);

Why Parameter Object?

  • Prevents domain layer from depending on application DTOs
  • Clean, type-safe factory method parameters
  • Easy to add new properties without changing signatures
  • NOT an entity or value object - just a parameter structure

Domain Events

Transaction Created Events

Pattern: Each transaction type raises a type-specific created event

// Base event (conceptual - not in codebase yet)
public abstract class TransactionCreatedDomainEvent : IDomainEvent
{
public Guid TransactionId { get; }
public TransactionType Type { get; }
public DateTime Timestamp { get; }
public Guid UserId { get; }
public IReadOnlyList<TransactionLineItem> LineItems { get; }
}

// Type-specific events
public class MovementTransactionCreatedEvent : TransactionCreatedDomainEvent
{
public Guid SourceLocationId { get; }
public Guid DestinationLocationId { get; }
}

public class AdjustmentTransactionCreatedEvent : TransactionCreatedDomainEvent
{
public Guid LocationId { get; }
public bool IsPositiveAdjustment { get; }
}

public class AssemblyTransactionCreatedEvent : TransactionCreatedDomainEvent
{
public Guid BillOfMaterialId { get; }
public Guid ProducedItemId { get; }
public decimal QuantityProduced { get; }
public Guid AssemblyLocationId { get; }
}

public class SalesTransactionCreatedEvent : TransactionCreatedDomainEvent
{
public Guid CustomerId { get; }
public Guid ShippingLocationId { get; }
public string InvoiceNumber { get; }
}

public class PurchaseTransactionCreatedEvent : TransactionCreatedDomainEvent
{
public Guid VendorId { get; }
public Guid ReceivingLocationId { get; }
public string InvoiceNumber { get; }
}

Handlers: Inventory update handlers listen to these events


Integration Points

Finance Module Active

Purchase Invoice Processing:

Sales Invoice Processing:

Error Handling:

  • If inventory fails, publishes ...PostingFailedIntegrationEvent
  • Finance halts posting, notifies user
  • Transaction can be retried or corrected

Inventory Module Internal

Domain Event Flow:


Technical Implementation Notes

Aggregate Root Characteristics

  • Identity: Id (Guid, auto-generated in constructor)
  • Aggregate Boundary: Includes LineItems as owned entities
  • Consistency Boundary: All line items validated together
  • Repository Pattern: Single repository IInventoryTransactionRepository
  • Unit of Work: Changes committed atomically

Entity Framework Configuration

Location: src/IM.Infrastructure/Persistence/Configurations/TransactionConfiguration/

Pattern: Fluent API with owned entities

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<string>()
.HasMaxLength(50);

builder.Property(t => t.Reason)
.IsRequired()
.HasMaxLength(500);

builder.Property(t => t.ExternalReference)
.HasMaxLength(100);

// Owned entities for type-specific details
builder.OwnsOne(t => t.MovementDetails, md =>
{
md.Property(d => d.MovementReference).HasMaxLength(100);
md.Property(d => d.Notes).HasMaxLength(1000);
});

builder.OwnsOne(t => t.AdjustmentDetails);
builder.OwnsOne(t => t.AssemblyDetails);
builder.OwnsOne(t => t.SalesDetails);
builder.OwnsOne(t => t.PurchaseDetails);

// Line items as separate table with foreign key
builder.HasMany<TransactionLineItem>()
.WithOne()
.HasForeignKey("TransactionId")
.OnDelete(DeleteBehavior.Cascade);

// Indexes for common queries
builder.HasIndex(t => t.Type);
builder.HasIndex(t => t.Timestamp);
builder.HasIndex(t => t.UserId);
builder.HasIndex(t => t.ExternalReference);
}
}

Performance Considerations

Indexing Strategy:

-- Transaction type queries
CREATE INDEX idx_transactions_type ON inventory_transactions(type);

-- Date range queries
CREATE INDEX idx_transactions_timestamp ON inventory_transactions(timestamp DESC);

-- User audit queries
CREATE INDEX idx_transactions_user ON inventory_transactions(user_id);

-- External reference lookups
CREATE INDEX idx_transactions_external_ref ON inventory_transactions(external_reference)
WHERE external_reference IS NOT NULL;

-- Composite index for filtered queries
CREATE INDEX idx_transactions_type_timestamp
ON inventory_transactions(type, timestamp DESC);

Query Optimization:

  • Use AsNoTracking() for read-only queries
  • Paginate large result sets
  • Project to DTOs to avoid loading unnecessary data
  • Eager load line items when needed: Include(t => t.LineItems)

Bulk Operations:

// Efficient bulk transaction creation
public async Task CreateBulkMovements(List<MovementRequest> requests)
{
var transactions = requests
.Select(r => InventoryTransaction.CreateMovement(...))
.ToList();

await _repository.AddRangeAsync(transactions);
await _unitOfWork.CommitAsync(); // Single database roundtrip
}

Concurrency Handling

Optimistic Locking (Future):

public class InventoryTransaction : AggregateRoot
{
public int Version { get; protected set; }

public void IncrementVersion() => Version++;
}

DbContext Configuration:

builder.Property(t => t.Version)
.IsConcurrencyToken();

Testing Strategies

Unit Tests - Aggregate Behavior

[TestFixture]
public class InventoryTransactionTests
{
[Test]
public void CreateMovement_ValidInputs_CreatesTransaction()
{
// Arrange
var userId = Guid.NewGuid();
var sourceId = Guid.NewGuid();
var destId = Guid.NewGuid();
var lineItems = new[]
{
new InventoryTransaction.LineItemData(Guid.NewGuid(), 10, Guid.NewGuid()),
new InventoryTransaction.LineItemData(Guid.NewGuid(), 5, Guid.NewGuid())
};

// Act
var transaction = InventoryTransaction.CreateMovement(
userId, sourceId, destId, lineItems, "Test movement");

// Assert
Assert.That(transaction, Is.Not.Null);
Assert.That(transaction.Type, Is.EqualTo(TransactionType.Movement));
Assert.That(transaction.UserId, Is.EqualTo(userId));
Assert.That(transaction.MovementDetails, Is.Not.Null);
Assert.That(transaction.MovementDetails.SourceLocationId, Is.EqualTo(sourceId));
Assert.That(transaction.MovementDetails.DestinationLocationId, Is.EqualTo(destId));
Assert.That(transaction.LineItems, Has.Count.EqualTo(2));
Assert.That(transaction.GetTotalQuantity(), Is.EqualTo(15));
}

[Test]
public void CreateMovement_SameSourceAndDestination_ThrowsException()
{
// Arrange
var sameLocationId = Guid.NewGuid();
var lineItems = new[] { new InventoryTransaction.LineItemData(Guid.NewGuid(), 10, Guid.NewGuid()) };

// Act & Assert
var ex = Assert.Throws<DomainException>(() =>
InventoryTransaction.CreateMovement(
Guid.NewGuid(),
sameLocationId,
sameLocationId, // SAME!
lineItems,
"Invalid movement"));

Assert.That(ex.Message, Does.Contain("cannot be the same"));
}

[Test]
public void CreateMovement_NullLineItems_ThrowsException()
{
// Act & Assert
Assert.Throws<DomainException>(() =>
InventoryTransaction.CreateMovement(
Guid.NewGuid(),
Guid.NewGuid(),
Guid.NewGuid(),
null, // NULL!
"Test"));
}

[Test]
public void CreateMovement_DuplicateItems_ThrowsException()
{
// Arrange
var itemId = Guid.NewGuid();
var lineItems = new[]
{
new InventoryTransaction.LineItemData(itemId, 10, Guid.NewGuid()),
new InventoryTransaction.LineItemData(itemId, 5, Guid.NewGuid()) // DUPLICATE!
};

// Act & Assert
var ex = Assert.Throws<DomainException>(() =>
InventoryTransaction.CreateMovement(
Guid.NewGuid(),
Guid.NewGuid(),
Guid.NewGuid(),
lineItems,
"Test"));

Assert.That(ex.Message, Does.Contain("duplicate items"));
}

[Test]
public void CreateAdjustment_PositiveAdjustment_CreatesCorrectTransaction()
{
// Arrange - Increase from 100 to 150
var currentQty = 100m;
var newQty = 150m;

// Act
var transaction = InventoryTransaction.CreateAdjustment(
Guid.NewGuid(),
Guid.NewGuid(),
currentQty,
newQty,
Guid.NewGuid(),
Guid.NewGuid(),
"Cycle count found extra inventory");

// Assert
Assert.That(transaction.Type, Is.EqualTo(TransactionType.Adjustment));
Assert.That(transaction.AdjustmentDetails.IsPositiveAdjustment, Is.True);
Assert.That(transaction.LineItems.First().Quantity, Is.EqualTo(50)); // Absolute value
}

[Test]
public void CreateAdjustment_NegativeAdjustment_CreatesCorrectTransaction()
{
// Arrange - Decrease from 100 to 95
var currentQty = 100m;
var newQty = 95m;

// Act
var transaction = InventoryTransaction.CreateAdjustment(
Guid.NewGuid(),
Guid.NewGuid(),
currentQty,
newQty,
Guid.NewGuid(),
Guid.NewGuid(),
"Cycle count found missing inventory");

// Assert
Assert.That(transaction.AdjustmentDetails.IsPositiveAdjustment, Is.False);
Assert.That(transaction.LineItems.First().Quantity, Is.EqualTo(5)); // Absolute value
}

[Test]
public void CreateAdjustment_NoChange_ThrowsException()
{
// Arrange - Same quantity
var qty = 100m;

// Act & Assert
var ex = Assert.Throws<DomainException>(() =>
InventoryTransaction.CreateAdjustment(
Guid.NewGuid(),
Guid.NewGuid(),
qty,
qty, // SAME!
Guid.NewGuid(),
Guid.NewGuid(),
"Test"));

Assert.That(ex.Message, Does.Contain("No adjustment needed"));
}

[Test]
public void CreateAssembly_ValidInputs_CreatesTransaction()
{
// Arrange
var components = new[]
{
new InventoryTransaction.LineItemData(Guid.NewGuid(), 10, Guid.NewGuid(), "Component A"),
new InventoryTransaction.LineItemData(Guid.NewGuid(), 5, Guid.NewGuid(), "Component B")
};

// Act
var transaction = InventoryTransaction.CreateAssembly(
userId: Guid.NewGuid(),
assemblyItemId: Guid.NewGuid(),
quantityProduced: 1,
unitOfMeasureId: Guid.NewGuid(),
assemblyLocationId: Guid.NewGuid(),
billOfMaterialId: Guid.NewGuid(),
consumedComponents: components,
reason: "Production order #12345");

// Assert
Assert.That(transaction.Type, Is.EqualTo(TransactionType.Assembly));
Assert.That(transaction.AssemblyDetails, Is.Not.Null);
Assert.That(transaction.AssemblyDetails.QuantityProduced, Is.EqualTo(1));
Assert.That(transaction.LineItems, Has.Count.EqualTo(2)); // Components only
}
}

Integration Tests - Repository

[TestFixture]
public class InventoryTransactionRepositoryTests
{
private IInventoryTransactionRepository _repository;
private IUnitOfWork _unitOfWork;
private AppDbContext _dbContext;

[SetUp]
public void Setup()
{
// Setup in-memory database
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;

_dbContext = new AppDbContext(options);
_repository = new InventoryTransactionRepository(_dbContext);
_unitOfWork = new UnitOfWork(_dbContext);
}

[Test]
public async Task AddAsync_ValidTransaction_SavesToDatabase()
{
// Arrange
var transaction = InventoryTransaction.CreateMovement(...);

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

// Assert
var saved = await _repository.GetByIdAsync(transaction.Id);
Assert.That(saved, Is.Not.Null);
Assert.That(saved.Type, Is.EqualTo(TransactionType.Movement));
Assert.That(saved.LineItems, Has.Count.EqualTo(transaction.LineItems.Count));
}

[Test]
public async Task GetByIdAsync_IncludesLineItems()
{
// Arrange
var transaction = InventoryTransaction.CreateMovement(...);
await _repository.AddAsync(transaction);
await _unitOfWork.CommitAsync();

// Act
var retrieved = await _repository.GetByIdAsync(transaction.Id);

// Assert
Assert.That(retrieved.LineItems, Is.Not.Empty);
}

[Test]
public async Task GetByTypeAsync_FiltersCorrectly()
{
// Arrange
var movement = InventoryTransaction.CreateMovement(...);
var adjustment = InventoryTransaction.CreateAdjustment(...);

await _repository.AddAsync(movement);
await _repository.AddAsync(adjustment);
await _unitOfWork.CommitAsync();

// Act
var movements = await _repository.GetByTypeAsync(TransactionType.Movement);

// Assert
Assert.That(movements, Has.Count.EqualTo(1));
Assert.That(movements.First().Type, Is.EqualTo(TransactionType.Movement));
}
}

Domain Event Tests

[TestFixture]
public class TransactionDomainEventTests
{
[Test]
public void CreateMovement_AddsDomainEventToAggregate()
{
// Arrange & Act
var transaction = InventoryTransaction.CreateMovement(...);

// Assert
Assert.That(transaction.DomainEvents, Is.Not.Empty);
Assert.That(transaction.DomainEvents, Has.Count.EqualTo(1));
var domainEvent = transaction.DomainEvents.First();
Assert.That(domainEvent, Is.TypeOf<MovementTransactionCreatedEvent>());
}

[Test]
public async Task CommitAsync_PublishesDomainEvents()
{
// This would be an integration test with real DbContext and MediatR
// Verify that domain events are published after SaveChangesAsync
}
}

Future Enhancements

Advanced Features

Return Transactions:

public static InventoryTransaction CreateReturn(
Guid userId,
Guid customerId,
Guid returnLocationId,
Guid originalSaleTransactionId,
IEnumerable<LineItemData> returnedItems,
string reason)

Transfer Transactions (Multi-location):

public static InventoryTransaction CreateMultiLocationTransfer(
Guid userId,
IEnumerable<LocationTransferPair> transfers,
string reason)

public record LocationTransferPair(
Guid SourceLocationId,
Guid DestinationLocationId,
LineItemData LineItem);

Void/Reversal Transactions:

  • Mechanism to mark transactions as voided
  • Create offsetting transactions
  • Maintain audit trail of voids

Serial Number Tracking:

public class TransactionLineItem
{
public List<string> SerialNumbers { get; private set; }
}

Lot/Batch Tracking:

public class TransactionLineItem
{
public string LotNumber { get; private set; }
public DateTime? ExpirationDate { get; private set; }
}

Performance Optimizations

Event Sourcing:

  • Store all transactions as event stream
  • Rebuild inventory state from events
  • Enable time-travel queries

CQRS Read Models:

  • Separate models for transaction queries
  • Pre-calculated summaries
  • Optimized indexes

Bulk Event Publishing:

  • Batch domain events for high-volume scenarios
  • Asynchronous event processing
  • Event batching and throttling

Implementation References

Value Object Documentation

Cross-Module References


Last Updated: 2025-10-23 | Version: 1.0 | Status: Production Ready

Dependencies: None (Pure domain aggregate) Referenced By: All transaction-related features, inventory tracking, finance integration Domain Services: None required - aggregate is self-contained