MovementDetails
File: src/IM.Domain/Aggregates/TransactionAggregate/ValueObjects/MovementDetails.cs
Type: Value Object
Module: Inventory - Transactions
Purpose
MovementDetails is a value object that encapsulates all type-specific information for Movement transactions. It represents the transfer of inventory from one location to another without changing the total inventory quantity in the system. This value object enforces the critical business rule that inventory cannot be moved to the same location it's already in.
Business Context
Movement Transactions in Inventory Management
Movement transactions are internal transfers that redistribute inventory across warehouse locations. Unlike sales (which decrease total inventory) or purchases (which increase total inventory), movements maintain the same total quantity but change the distribution.
Common Business Scenarios:
- Warehouse Transfers: Moving stock from main warehouse to retail locations
- Production Replenishment: Moving raw materials from storage to production floor
- Reorganization: Consolidating inventory during facility reorganization
- Returns to Stock: Moving damaged/returned items to inspection area
- Cross-Docking: Moving received goods directly to shipping area
Business Value:
- Maintains accurate location-level inventory
- Enables efficient warehouse operations
- Supports multi-location inventory visibility
- Creates audit trail for inventory redistribution
Value Object Properties
SourceLocationId
Type: Guid
Required: Yes
Purpose: Identifies the location where inventory is being removed from.
Business Rules:
- Cannot be empty (Guid.Empty)
- Must reference an existing, operational location (validated at application layer)
- Cannot be the same as DestinationLocationId
Example Values:
- Warehouse location ID
- Bin location ID
- Production floor location ID
DestinationLocationId
Type: Guid
Required: Yes
Purpose: Identifies the location where inventory is being added to.
Business Rules:
- Cannot be empty (Guid.Empty)
- Must reference an existing, operational location (validated at application layer)
- Cannot be the same as SourceLocationId
Example Values:
- Retail store location ID
- Shipping dock location ID
- Quality control area location ID
MovementReference
Type: string? (nullable)
Required: No
Max Length: 100 characters
Purpose: Optional reference number or code for tracking the movement.
Business Usage:
- Transfer order numbers (e.g., "TR-2024-001")
- Work order references (e.g., "WO-12345")
- Internal ticket numbers
- External system references
Validation:
- Trimmed automatically
- Cannot exceed 100 characters
- Null or empty is allowed
Example Values:
"TR-2024-001"- Transfer request"RESTOCK-WEST-01"- Restocking operation"CONSOLIDATE-Q1"- Quarterly consolidation
Notes
Type: string (non-nullable, but can be empty)
Required: No (defaults to empty string)
Max Length: 500 characters
Purpose: Additional context or instructions for the movement.
Business Usage:
- Special handling instructions
- Reason for movement
- Additional context for audit
Validation:
- Trimmed automatically
- Cannot exceed 500 characters
- Defaults to empty string if null
Example Values:
"Urgent - needed for production run #5678""Consolidating slow-moving inventory""Moving to climate-controlled storage due to temperature concerns"
Factory Methods
Create (Primary)
Purpose: Creates a MovementDetails value object with full validation.
public static MovementDetails Create(
Guid sourceLocationId,
Guid destinationLocationId,
string? movementReference = null,
string? notes = null)
Validation Rules:
- Source location ID cannot be empty
- Destination location ID cannot be empty
- Source ≠ Destination (critical business rule)
- Movement reference max 100 characters
- Notes max 500 characters
Usage Example:
var movementDetails = MovementDetails.Create(
sourceLocationId: warehouseMainId,
destinationLocationId: retailStoreId,
movementReference: "TR-2024-001",
notes: "Weekly restocking shipment");
Throws: DomainException if validation fails
CreateSimple
Purpose: Convenience method for basic movements without reference or notes.
public static MovementDetails CreateSimple(
Guid sourceLocationId,
Guid destinationLocationId)
Usage Example:
var movementDetails = MovementDetails.CreateSimple(
sourceLocationId: warehouseId,
destinationLocationId: productionFloorId);
Best For:
- Internal transfers without tracking requirements
- Ad-hoc movements
- Quick redistributions
CreateWithReference
Purpose: Convenience method for movements with tracking reference.
public static MovementDetails CreateWithReference(
Guid sourceLocationId,
Guid destinationLocationId,
string transferReference)
Usage Example:
var movementDetails = MovementDetails.CreateWithReference(
sourceLocationId: centralWarehouseId,
destinationLocationId: branchWarehouseId,
transferReference: "TRANSFER-ORDER-5678");
Best For:
- Formal transfer requests
- Tracked shipments
- Movements requiring approval
Business Rules & Validation
Same Location Prevention
Rule: Source and destination locations cannot be the same.
Rationale:
- Moving inventory to the same location it's already in makes no business sense
- Prevents accidental duplicate transactions
- Ensures data integrity
Enforcement:
if (sourceLocationId == destinationLocationId)
throw new DomainException("Source and destination locations cannot be the same");
User Impact:
- User interface should prevent selecting same location for both source and destination
- API will reject requests with same source and destination
- Clear error message guides user to correct the issue
Location Existence Validation
Rule: Both locations must exist and be operational.
Validation Level: Application layer (not in value object)
Rationale:
- Value objects don't have access to repositories
- Location validation requires database queries
- Separation of concerns (domain vs. infrastructure)
Implementation Pattern:
// In application layer handler
public class CreateBulkMovementCommandHandler
{
public async Task<MovementDto> Handle(CreateBulkMovementCommand request, ...)
{
// Validate locations exist and are operational
var sourceLocation = await _locationRepo.GetByIdAsync(request.SourceLocationId);
if (sourceLocation == null || !sourceLocation.IsOperational)
throw new ApplicationException("Source location is not available");
var destLocation = await _locationRepo.GetByIdAsync(request.DestinationLocationId);
if (destLocation == null || !destLocation.IsOperational)
throw new ApplicationException("Destination location is not available");
// Now safe to create MovementDetails
var movementDetails = MovementDetails.Create(
request.SourceLocationId,
request.DestinationLocationId,
request.MovementReference,
request.Notes);
// Continue with transaction creation...
}
}
Domain Methods
GetMovementDescription
Purpose: Generates human-readable description of the movement.
public string GetMovementDescription()
{
var description = $"Movement from {SourceLocationId:N} to {DestinationLocationId:N}";
if (!string.IsNullOrEmpty(MovementReference))
description += $" (Ref: {MovementReference})";
return description;
}
Output Examples:
"Movement from 3fa85f64-5717-4562-b3fc-2c963f66afa6 to 4gb96g75-6828-5673-c4gd-3d074g77bgb7""Movement from 3fa85f64-5717-4562-b3fc-2c963f66afa6 to 4gb96g75-6828-5673-c4gd-3d074g77bgb7 (Ref: TR-2024-001)"
Usage:
- Audit logs
- Transaction history display
- Error messages
- Notifications
IsIntraWarehouseMovement
Purpose: Determines if movement is within same warehouse facility.
public bool IsIntraWarehouseMovement()
{
// MVP: Simple implementation - always return true
// Future: Could check if both locations belong to same warehouse
return true;
}
Current Implementation: Placeholder (always returns true)
Future Enhancement:
public bool IsIntraWarehouseMovement(ILocationRepository locationRepo)
{
var sourceWarehouse = locationRepo.GetWarehouseForLocation(SourceLocationId);
var destWarehouse = locationRepo.GetWarehouseForLocation(DestinationLocationId);
return sourceWarehouse?.Id == destWarehouse?.Id;
}
Business Application:
- Different approval workflows for inter-warehouse vs intra-warehouse
- Shipping documentation only needed for inter-warehouse
- Different lead times for different movement types
- Reporting and analytics segmentation
Value Object Equality
Equality Components
Implementation: Value equality based on all properties
protected override IEnumerable<object> GetEqualityComponents()
{
yield return SourceLocationId;
yield return DestinationLocationId;
yield return MovementReference ?? string.Empty;
yield return Notes ?? string.Empty;
}
Equality Logic: Two MovementDetails are equal if all four properties match:
- Same source location
- Same destination location
- Same movement reference (or both null/empty)
- Same notes (or both null/empty)
Example:
var details1 = MovementDetails.Create(loc1, loc2, "REF-001", "Test");
var details2 = MovementDetails.Create(loc1, loc2, "REF-001", "Test");
var details3 = MovementDetails.Create(loc1, loc2, "REF-002", "Test");
Assert.That(details1, Is.EqualTo(details2)); // True - all properties match
Assert.That(details1, Is.EqualTo(details3)); // False - different reference
Business Implication:
- Duplicate detection
- Deduplication logic
- Caching strategies
Integration with InventoryTransaction
Composition Pattern
MovementDetails is composed into InventoryTransaction when transaction type is Movement.
public class InventoryTransaction : AggregateRoot
{
public MovementDetails? MovementDetails { get; private set; }
public static InventoryTransaction CreateMovement(
Guid userId,
Guid sourceLocationId,
Guid destinationLocationId,
IEnumerable<LineItemData> lineItemData,
string reason,
string? externalReference = null,
string? movementReference = null,
string? notes = null)
{
// Validate and create transaction
var transaction = new InventoryTransaction(
TransactionType.Movement,
userId,
DateTime.UtcNow,
reason,
externalReference);
// Create and attach MovementDetails
transaction.MovementDetails = MovementDetails.Create(
sourceLocationId,
destinationLocationId,
movementReference,
notes);
// Add line items...
return transaction;
}
}
Pattern Benefits:
- Type-specific data only present when relevant
- No inheritance hierarchy needed
- Easy to add new transaction types
- Clear separation of concerns
Database Persistence
Entity Framework Configuration
Owned Entity: MovementDetails is configured as an owned entity in EF Core.
public class InventoryTransactionConfiguration : IEntityTypeConfiguration<InventoryTransaction>
{
public void Configure(EntityTypeBuilder<InventoryTransaction> builder)
{
builder.OwnsOne(t => t.MovementDetails, md =>
{
md.Property(d => d.SourceLocationId)
.HasColumnName("movement_source_location_id")
.IsRequired(false); // Null when Type != Movement
md.Property(d => d.DestinationLocationId)
.HasColumnName("movement_destination_location_id")
.IsRequired(false);
md.Property(d => d.MovementReference)
.HasColumnName("movement_reference")
.HasMaxLength(100)
.IsRequired(false);
md.Property(d => d.Notes)
.HasColumnName("movement_notes")
.HasMaxLength(500)
.IsRequired(false);
});
}
}
Database Schema:
CREATE TABLE inventory_transactions (
id UUID PRIMARY KEY,
type VARCHAR(50) NOT NULL,
-- ... other transaction fields ...
-- MovementDetails (nullable columns)
movement_source_location_id UUID,
movement_destination_location_id UUID,
movement_reference VARCHAR(100),
movement_notes VARCHAR(500),
-- Foreign keys
FOREIGN KEY (movement_source_location_id) REFERENCES locations(id),
FOREIGN KEY (movement_destination_location_id) REFERENCES locations(id)
);
Null Pattern:
- Columns are NULL when transaction Type != Movement
- Avoids separate join table
- Efficient storage and queries
Usage Examples
Example 1: Simple Warehouse Transfer
// Business scenario: Move items from main warehouse to retail store
var transaction = InventoryTransaction.CreateMovement(
userId: currentUser.Id,
sourceLocationId: mainWarehouseId,
destinationLocationId: retailStore01Id,
lineItemData: new[]
{
new InventoryTransaction.LineItemData(item1Id, 50, eachUnitId),
new InventoryTransaction.LineItemData(item2Id, 25, eachUnitId)
},
reason: "Weekly restocking for Store #01",
movementReference: "RESTOCK-2024-W12");
// MovementDetails contains:
// - SourceLocationId: mainWarehouseId
// - DestinationLocationId: retailStore01Id
// - MovementReference: "RESTOCK-2024-W12"
// - Notes: "" (empty)
Example 2: Production Floor Replenishment
// Business scenario: Move raw materials to production area
var transaction = InventoryTransaction.CreateMovement(
userId: productionManager.Id,
sourceLocationId: rawMaterialsStorageId,
destinationLocationId: productionFloorLineAId,
lineItemData: new[]
{
new InventoryTransaction.LineItemData(steelPlateItemId, 100, kgUnitId),
new InventoryTransaction.LineItemData(boltsItemId, 500, eachUnitId)
},
reason: "Production order #PO-5678 material requirement",
movementReference: "PO-5678",
notes: "Urgent - production scheduled for tomorrow 8 AM");
// MovementDetails contains:
// - SourceLocationId: rawMaterialsStorageId
// - DestinationLocationId: productionFloorLineAId
// - MovementReference: "PO-5678"
// - Notes: "Urgent - production scheduled for tomorrow 8 AM"
Example 3: Returns to Inspection Area
// Business scenario: Move customer returns to QC area
var transaction = InventoryTransaction.CreateMovement(
userId: receivingClerkId,
sourceLocationId: receivingDockId,
destinationLocationId: qcInspectionAreaId,
lineItemData: new[]
{
new InventoryTransaction.LineItemData(returnedItemId, 3, eachUnitId, "Customer return - inspect for damage")
},
reason: "Customer return from order #12345 - requires inspection",
movementReference: "RMA-2024-0089",
notes: "Customer reported item not working - verify functionality");
// MovementDetails contains:
// - SourceLocationId: receivingDockId
// - DestinationLocationId: qcInspectionAreaId
// - MovementReference: "RMA-2024-0089"
// - Notes: "Customer reported item not working - verify functionality"
Testing Strategies
Unit Tests - Value Object Creation
[TestFixture]
public class MovementDetailsTests
{
[Test]
public void Create_ValidInputs_CreatesMovementDetails()
{
// Arrange
var sourceId = Guid.NewGuid();
var destId = Guid.NewGuid();
// Act
var details = MovementDetails.Create(sourceId, destId, "REF-001", "Test notes");
// Assert
Assert.That(details, Is.Not.Null);
Assert.That(details.SourceLocationId, Is.EqualTo(sourceId));
Assert.That(details.DestinationLocationId, Is.EqualTo(destId));
Assert.That(details.MovementReference, Is.EqualTo("REF-001"));
Assert.That(details.Notes, Is.EqualTo("Test notes"));
}
[Test]
public void Create_SameSourceAndDestination_ThrowsException()
{
// Arrange
var sameLocationId = Guid.NewGuid();
// Act & Assert
var ex = Assert.Throws<DomainException>(() =>
MovementDetails.Create(sameLocationId, sameLocationId));
Assert.That(ex.Message, Does.Contain("cannot be the same"));
}
[Test]
public void Create_EmptySourceLocation_ThrowsException()
{
// Act & Assert
var ex = Assert.Throws<DomainException>(() =>
MovementDetails.Create(Guid.Empty, Guid.NewGuid()));
Assert.That(ex.Message, Does.Contain("Source location ID cannot be empty"));
}
[Test]
public void Create_EmptyDestinationLocation_ThrowsException()
{
// Act & Assert
var ex = Assert.Throws<DomainException>(() =>
MovementDetails.Create(Guid.NewGuid(), Guid.Empty));
Assert.That(ex.Message, Does.Contain("Destination location ID cannot be empty"));
}
[Test]
public void Create_TooLongReference_ThrowsException()
{
// Arrange
var longReference = new string('X', 101); // 101 characters
// Act & Assert
var ex = Assert.Throws<DomainException>(() =>
MovementDetails.Create(Guid.NewGuid(), Guid.NewGuid(), longReference));
Assert.That(ex.Message, Does.Contain("cannot exceed 100 characters"));
}
[Test]
public void Create_TooLongNotes_ThrowsException()
{
// Arrange
var longNotes = new string('X', 501); // 501 characters
// Act & Assert
var ex = Assert.Throws<DomainException>(() =>
MovementDetails.Create(Guid.NewGuid(), Guid.NewGuid(), null, longNotes));
Assert.That(ex.Message, Does.Contain("cannot exceed 500 characters"));
}
[Test]
public void CreateSimple_CreatesMinimalDetails()
{
// Arrange
var sourceId = Guid.NewGuid();
var destId = Guid.NewGuid();
// Act
var details = MovementDetails.CreateSimple(sourceId, destId);
// Assert
Assert.That(details.SourceLocationId, Is.EqualTo(sourceId));
Assert.That(details.DestinationLocationId, Is.EqualTo(destId));
Assert.That(details.MovementReference, Is.Null);
Assert.That(details.Notes, Is.Empty);
}
[Test]
public void Equality_SameValues_AreEqual()
{
// Arrange
var sourceId = Guid.NewGuid();
var destId = Guid.NewGuid();
// Act
var details1 = MovementDetails.Create(sourceId, destId, "REF", "Notes");
var details2 = MovementDetails.Create(sourceId, destId, "REF", "Notes");
// Assert
Assert.That(details1, Is.EqualTo(details2));
}
[Test]
public void Equality_DifferentReference_NotEqual()
{
// Arrange
var sourceId = Guid.NewGuid();
var destId = Guid.NewGuid();
// Act
var details1 = MovementDetails.Create(sourceId, destId, "REF-001", "Notes");
var details2 = MovementDetails.Create(sourceId, destId, "REF-002", "Notes");
// Assert
Assert.That(details1, Is.Not.EqualTo(details2));
}
}
Future Enhancements
Location Hierarchy Integration
Feature: Validate movements based on location hierarchy rules
public static MovementDetails CreateWithHierarchyValidation(
Guid sourceLocationId,
Guid destinationLocationId,
ILocationRepository locationRepo,
string? movementReference = null,
string? notes = null)
{
// Validate locations are in compatible hierarchy levels
var source = await locationRepo.GetByIdAsync(sourceLocationId);
var dest = await locationRepo.GetByIdAsync(destinationLocationId);
// Business rule: Can't move from child to parent directly
if (dest.IsAncestorOf(source))
throw new DomainException("Cannot move inventory from child to parent location");
return Create(sourceLocationId, destinationLocationId, movementReference, notes);
}
Movement Authorization
Feature: Track who authorized the movement
public class MovementDetails : ValueObject
{
public Guid? AuthorizedBy { get; private set; }
public DateTime? AuthorizedAt { get; private set; }
// Require authorization for inter-warehouse movements
public static MovementDetails CreateWithAuthorization(
Guid sourceLocationId,
Guid destinationLocationId,
Guid authorizedBy,
string? movementReference = null,
string? notes = null)
{
var details = Create(sourceLocationId, destinationLocationId, movementReference, notes);
details.AuthorizedBy = authorizedBy;
details.AuthorizedAt = DateTime.UtcNow;
return details;
}
}
Estimated Time and Cost
Feature: Track expected movement duration and cost
public class MovementDetails : ValueObject
{
public TimeSpan? EstimatedDuration { get; private set; }
public decimal? EstimatedCost { get; private set; }
public string? ShippingMethod { get; private set; }
}
Related Documentation
Transaction Documentation
- InventoryTransaction Aggregate - Parent aggregate
- Transaction Types Concept - Business workflows
Other Value Objects
- AdjustmentDetails - Adjustment type details
- AssemblyDetails - Assembly type details
- SalesDetails - Sales type details
- PurchaseDetails - Purchase type details
API Documentation
- Movements API - Movement transaction endpoints
Last Updated: 2025-10-23 | Version: 1.0 | Status: Production Ready
Dependencies: None (Pure value object) Referenced By: InventoryTransaction aggregate Validation: Self-contained within value object