Skip to main content

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:

  1. Source location ID cannot be empty
  2. Destination location ID cannot be empty
  3. Source ≠ Destination (critical business rule)
  4. Movement reference max 100 characters
  5. 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; }
}

Transaction Documentation

Other Value Objects

API Documentation


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