The Inventory Service serves as the single source of truth for all product stock levels across the fulfillment network. It manages inventory quantities, allocations, reservations, locations, and provides a comprehensive audit trail through an immutable ledger pattern.
Architecture Pattern: Hexagonal Architecture with Domain-Driven Design (DDD) Technology Stack: Spring Boot 3.2, Spring Data MongoDB, Spring Kafka, CloudEvents Integration Pattern: Event-Driven Architecture with Transactional Outbox Pattern
The Inventory Management bounded context encompasses all aspects of tracking, allocating, and managing product stock across the fulfillment network.
Responsibilities (Whatβs IN):
External Dependencies (Whatβs OUT):
Core Domain Terms:
Strategic Importance: HIGH - Critical business capability
This is the heart of the inventory service and provides significant business value through:
Subdomains:
Description: Manages all stock information for a product (SKU) including quantities, allocations, and reservations.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@AggregateRoot
public class ProductStock {
private String sku; // Aggregate ID
private StockLevel stockLevel;
private SafetyStockLevel safetyStock;
private List<Allocation> allocations;
private int version; // Optimistic locking
// Business methods
public AllocationResult allocate(String orderId, int quantity, Duration ttl);
public void deallocate(String allocationId);
public int calculateAvailableToPromise();
public void adjustStock(int quantityChange, AdjustmentReason reason);
public boolean hasSufficientATP(int requestedQuantity);
// Invariants
private void ensureQuantityOnHandNotNegative();
private void ensureAllocatedDoesNotExceedOnHand();
}
Invariants:
Domain Events:
StockLevelChangedEventInventoryAllocatedEventInventoryDeallocatedEventLowStockAlertEventDescription: Manages inventory at a specific physical location within the warehouse, including physical reservations for picking.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@AggregateRoot
public class StockLocation {
private String locationId; // Composite: SKU + Location
private String sku;
private String warehouseId;
private String zone;
private String aisle;
private String bin;
private int quantityAtLocation;
private List<PhysicalReservation> reservations;
// Business methods
public PhysicalReservation createReservation(
String orderId,
int quantity,
LocalDateTime expiresAt
);
public void fulfillReservation(String reservationId, int quantityPicked);
public void cancelReservation(String reservationId);
public int calculateAvailableAtLocation();
// Invariants
private void ensureReservationsDoNotExceedQuantity();
private void ensureLocationExists();
}
Invariants:
Domain Events:
PhysicalReservationCreatedEventReservationFulfilledEventReservationCancelledEventStockTransferredEventDescription: Immutable record of inventory transaction for complete audit trail.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@AggregateRoot
public class InventoryLedgerEntry {
private String entryId; // Aggregate ID
private String sku;
private TransactionType transactionType;
private int quantityBefore;
private int quantityAfter;
private int quantityChange;
private AdjustmentReason reasonCode;
private LocalDateTime timestamp;
private String userId;
private String referenceId; // Order ID, Receipt ID, etc.
private Map<String, String> metadata;
// Immutable - no business methods that change state
// Only factory method for creation
public static InventoryLedgerEntry create(
String sku,
TransactionType type,
int quantityBefore,
int quantityAfter,
AdjustmentReason reason,
String userId,
String referenceId
);
}
Invariants:
Domain Events:
LedgerEntryCreatedEventDescription: Represents allocation of inventory for a specific order.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
public class Allocation {
private String allocationId;
private String orderId;
private int quantityAllocated;
private AllocationStatus status;
private LocalDateTime allocatedAt;
private LocalDateTime expiresAt;
private Priority priority;
public boolean isExpired();
public void expire();
public void fulfill();
}
Description: Location-specific reservation for picking.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity
public class PhysicalReservation {
private String reservationId;
private String orderId;
private String pickListId;
private int quantityReserved;
private int quantityPicked;
private ReservationStatus status;
private LocalDateTime reservedAt;
private LocalDateTime expiresAt;
public void recordPick(int quantity);
public boolean isFullyPicked();
public void cancel(String reason);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@ValueObject
public class StockLevel {
private int quantityOnHand;
private int quantityAllocated;
private int quantityReserved; // Physical reservations
public int calculateAvailable() {
return quantityOnHand - quantityAllocated;
}
public StockLevel adjustQuantityOnHand(int adjustment) {
return new StockLevel(
quantityOnHand + adjustment,
quantityAllocated,
quantityReserved
);
}
public StockLevel allocate(int quantity) {
if (quantity > calculateAvailable()) {
throw new InsufficientInventoryException();
}
return new StockLevel(
quantityOnHand,
quantityAllocated + quantity,
quantityReserved
);
}
}
1
2
3
4
5
6
7
8
9
@ValueObject
public class SafetyStockLevel {
private int minimumLevel;
private int reorderPoint;
private int maximumLevel;
public boolean isBelowSafety(int currentQuantity);
public boolean shouldReorder(int currentQuantity);
}
1
2
3
4
5
6
7
8
9
10
@ValueObject
public class Location {
private String warehouseId;
private String zone;
private String aisle;
private String bin;
public String toLocationCode(); // e.g., "WH01-Z01-A05-B12"
public boolean isInZone(String zoneId);
}
Responsibility: Orchestrate inventory allocation across locations and handle concurrency.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@DomainService
public class AllocationService {
public AllocationResult allocateInventory(
String sku,
String orderId,
int quantity,
AllocationStrategy strategy
);
public void deallocateInventory(String sku, String allocationId);
public void handleAllocationExpiration(String sku, String allocationId);
public List<StockLocation> findLocationsWithAvailableStock(
String sku,
int requiredQuantity
);
}
Responsibility: Reconcile physical inventory counts with system records.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@DomainService
public class InventoryReconciliationService {
public ReconciliationResult reconcile(
String sku,
String locationId,
int physicalCount,
String countedBy
);
public void processVariance(
String sku,
int systemQuantity,
int physicalQuantity,
VarianceApproval approval
);
public BigDecimal calculateInventoryAccuracy(
LocalDate from,
LocalDate to
);
}
Responsibility: Calculate Available-to-Promise across locations and time horizons.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@DomainService
public class ATPCalculationService {
public int calculateCurrentATP(String sku);
public int calculateLocationATP(String sku, String warehouseId);
public Map<LocalDate, Integer> calculateFutureATP(
String sku,
int daysAhead,
List<PlannedReceipt> plannedReceipts
);
public boolean canFulfill(String sku, int quantity, String warehouseId);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Commands
public interface AllocateInventoryCommand {
AllocationResult execute(AllocateInventoryRequest request);
}
public interface DeallocateInventoryCommand {
void execute(DeallocateInventoryRequest request);
}
public interface AdjustStockLevelCommand {
void execute(StockAdjustmentRequest request);
}
public interface CreatePhysicalReservationCommand {
PhysicalReservation execute(CreateReservationRequest request);
}
// Queries
public interface GetATPQuery {
int execute(String sku);
}
public interface GetStockLevelQuery {
StockLevel execute(String sku);
}
public interface GetInventoryLedgerQuery {
List<InventoryLedgerEntry> execute(String sku, DateRange dateRange);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Repository ports
public interface ProductStockRepository {
Optional<ProductStock> findBySku(String sku);
void save(ProductStock productStock);
List<ProductStock> findBySkus(List<String> skus);
}
public interface StockLocationRepository {
Optional<StockLocation> findBySkuAndLocation(String sku, String locationId);
List<StockLocation> findBySku(String sku);
void save(StockLocation stockLocation);
}
public interface InventoryLedgerRepository {
void append(InventoryLedgerEntry entry);
List<InventoryLedgerEntry> findBySku(String sku, Pageable pageable);
}
// External service ports
public interface ProductCatalogClient {
boolean productExists(String sku);
ProductInfo getProductInfo(String sku);
}
public interface EventPublisher {
void publish(DomainEvent event);
}
// Outbox for reliable event publishing
public interface OutboxRepository {
void save(OutboxEvent event);
List<OutboxEvent> findUnpublished(int batchSize);
void markAsPublished(String eventId);
}
REST Controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@RestController
@RequestMapping("/api/v1/inventory")
public class InventoryController {
@GetMapping("/{sku}/atp")
public ResponseEntity<ATPResponse> getATP(@PathVariable String sku);
@PostMapping("/allocate")
public ResponseEntity<AllocationResponse> allocate(
@RequestBody AllocateRequest request
);
@PostMapping("/deallocate")
public ResponseEntity<Void> deallocate(
@RequestBody DeallocateRequest request
);
@PostMapping("/{sku}/adjust")
public ResponseEntity<Void> adjustStock(
@PathVariable String sku,
@RequestBody StockAdjustmentRequest request
);
@GetMapping("/{sku}/ledger")
public ResponseEntity<List<LedgerEntryResponse>> getLedger(
@PathVariable String sku,
@RequestParam(required = false) LocalDate from,
@RequestParam(required = false) LocalDate to
);
}
Event Listener
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class OrderEventListener {
@KafkaListener(topics = "fulfillment.order.v1.events")
public void handleOrderValidated(FulfillmentOrderValidatedEvent event) {
// Allocate inventory for validated order
}
@KafkaListener(topics = "fulfillment.order.v1.events")
public void handleOrderCancelled(FulfillmentOrderCancelledEvent event) {
// Deallocate inventory for cancelled order
}
}
@Component
public class WarehouseEventListener {
@KafkaListener(topics = "fulfillment.warehouse.v1.events")
public void handleItemPicked(ItemPickedEvent event) {
// Decrement inventory and allocation
}
}
MongoDB Adapter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Repository
public class MongoProductStockRepository implements ProductStockRepository {
private final MongoTemplate mongoTemplate;
@Override
public Optional<ProductStock> findBySku(String sku) {
return Optional.ofNullable(
mongoTemplate.findById(sku, ProductStock.class)
);
}
@Override
public void save(ProductStock productStock) {
mongoTemplate.save(productStock);
}
}
Transactional Outbox Adapter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Component
public class OutboxEventPublisher implements EventPublisher {
private final OutboxRepository outboxRepository;
@Override
@Transactional
public void publish(DomainEvent event) {
// Save event to outbox in same transaction as domain change
OutboxEvent outboxEvent = new OutboxEvent(
event.getId(),
event.getType(),
event.toJson(),
OutboxStatus.PENDING
);
outboxRepository.save(outboxEvent);
}
}
@Component
@Scheduled(fixedDelay = 1000) // Poll every second
public class OutboxProcessor {
public void processOutbox() {
List<OutboxEvent> pending = outboxRepository.findUnpublished(100);
for (OutboxEvent event : pending) {
try {
kafkaTemplate.send(topicName, event.getPayload());
outboxRepository.markAsPublished(event.getId());
} catch (Exception e) {
// Retry logic with exponential backoff
}
}
}
}
Cache Adapter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class InventoryCacheService {
private final RedisTemplate<String, Object> redisTemplate;
public Optional<Integer> getCachedATP(String sku) {
String key = "atp:" + sku;
return Optional.ofNullable(
(Integer) redisTemplate.opsForValue().get(key)
);
}
public void cacheATP(String sku, int atp, Duration ttl) {
String key = "atp:" + sku;
redisTemplate.opsForValue().set(key, atp, ttl);
}
public void invalidateATP(String sku) {
String key = "atp:" + sku;
redisTemplate.delete(key);
}
}
Single source of truth for all product stock levels across the fulfillment network.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"specversion": "1.0",
"type": "com.paklog.inventory.allocated",
"source": "inventory-service",
"id": "alloc-12345",
"time": "2025-10-18T10:30:00Z",
"datacontenttype": "application/json",
"data": {
"allocationId": "alloc-12345",
"sku": "PROD-123",
"orderId": "ORD-67890",
"quantityAllocated": 5,
"allocatedAt": "2025-10-18T10:30:00Z",
"expiresAt": "2025-10-19T10:30:00Z",
"warehouseId": "WH01"
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"specversion": "1.0",
"type": "com.paklog.inventory.stock.changed",
"source": "inventory-service",
"id": "stock-change-456",
"time": "2025-10-18T10:30:00Z",
"datacontenttype": "application/json",
"data": {
"sku": "PROD-123",
"quantityBefore": 100,
"quantityAfter": 95,
"quantityChange": -5,
"transactionType": "PICK",
"reasonCode": "ORDER_FULFILLMENT",
"referenceId": "ORD-67890",
"warehouseId": "WH01",
"userId": "picker-001"
}
}
The Inventory Service is a critical bounded context providing:
Business Impact: 99.5%+ accuracy, real-time ATP, 100% traceability, <50ms query latency.