Skip to content

Commit cd7ff04

Browse files
committed
fix: reserve stock before charging and validate order quantity
The order flow is now reserve, charge, release on failure. Inventory rejects non-positive quantities and its errors are surfaced, the access policy runs before the registry lookup, the statelessness and interoperability wording is corrected, and confirmations carry the customer id. The class diagram is a rendered PNG.
1 parent bf16d6e commit cd7ff04

15 files changed

Lines changed: 270 additions & 70 deletions

‎service-oriented-architecture/README.md‎

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ flowchart LR
4949
Order -.->|orchestrates via bus| Bus
5050
```
5151

52-
Because every service is stateless between calls, each one can be scaled independently. In the example the in-memory maps stand in for each service's own datastore, and the bus stands in for the network protocol (SOAP, REST or messaging) that would carry the messages in a deployed system.
52+
Because no service keeps per-conversation session state, each one can be scaled independently; the state a service does own is shared between all its callers and guarded for concurrent access. In the example the in-memory maps stand in for each service's own datastore, and the bus stands in for the network protocol (SOAP, REST or messaging) that would carry the messages in a deployed system.
53+
54+
![Service-Oriented Architecture class diagram](./etc/service-oriented-architecture.urm.png)
5355

5456
## Programmatic Example of Service-Oriented Architecture Pattern in Java
5557

@@ -64,7 +66,7 @@ public interface Service {
6466
}
6567
```
6668

67-
Messages are coarse-grained and self-describing. The payload is plain data, which is what makes the contract interoperable: the same request could travel as SOAP, JSON or any other wire format.
69+
Messages are coarse-grained and self-describing. The request payload is plain data, which is what makes the contract interoperable: the same request could travel as SOAP, JSON or any other wire format. Responses in this demo carry typed Java objects for brevity; a deployed system would describe them as plain data too.
6870

6971
```java
7072
public record ServiceRequest(
@@ -102,7 +104,7 @@ public class ServiceRegistry {
102104
}
103105
```
104106

105-
The bus is the communication backbone. It resolves the target through the registry, applies cross-cutting concerns in a single place (access control, tracing, timing) and turns provider failures into error responses so that consumers never see provider exceptions.
107+
The bus is the communication backbone. It applies cross-cutting concerns in a single place (access control, tracing, timing), resolves the target through the registry and turns provider failures into error responses so that consumers never see provider exceptions. The access check runs before the lookup, so a caller that may not reach a service learns nothing about what the registry holds.
106108

107109
```java
108110
@Slf4j
@@ -113,15 +115,15 @@ public class ServiceBus {
113115
public ServiceBus(ServiceRegistry registry, AccessPolicy policy) { ... }
114116

115117
public ServiceResponse send(ServiceRequest request) {
116-
var service = registry.lookup(request.service());
117-
if (service.isEmpty()) {
118-
return ServiceResponse.error("No such service: " + request.service());
119-
}
120118
if (!policy.allows(request)) {
121119
LOGGER.warn("Access denied to {}.{} for {} caller", request.service(), request.operation(),
122120
request.credential() == null ? "anonymous" : "credentialed");
123121
return ServiceResponse.error("Access denied to " + request.service());
124122
}
123+
var service = registry.lookup(request.service());
124+
if (service.isEmpty()) {
125+
return ServiceResponse.error("No such service: " + request.service());
126+
}
125127
LOGGER.info("-> {}.{} payload={}", request.service(), request.operation(), request.payload());
126128
var start = System.nanoTime();
127129
try {
@@ -160,7 +162,7 @@ var denied = bus.send(new ServiceRequest("payment", "charge",
160162
// denied = ServiceResponse[success=false, body=null, message=Access denied to payment]
161163
```
162164

163-
Each enterprise service owns exactly one business capability and is stateless between calls. Operations are dispatched with a switch expression on the operation name.
165+
Each enterprise service owns exactly one business capability and keeps no per-conversation session state; the state it does own is private to it and guarded for concurrent access. Operations are dispatched with a switch expression on the operation name.
164166

165167
```java
166168
public class CustomerService implements Service {
@@ -177,10 +179,12 @@ public class CustomerService implements Service {
177179
}
178180
```
179181

180-
`InventoryService` answers `checkStock` and `reserve`, and `PaymentService` answers `charge` in the same style.
182+
`InventoryService` answers `checkStock`, `reserve` and `release`, and `PaymentService` answers `charge` in the same style. Both reject a non-positive quantity or amount, because a service validates its own input rather than trusting its callers.
181183

182184
The composite `OrderService` is where SOA shows its strength. It implements the same contract as the others, but delivers a higher level capability by orchestrating the lower level services through the bus. It depends only on service names and message contracts, never on the provider classes, and it forwards the caller's credential so the bus can authorise every downstream call.
183185

186+
No transaction spans the services, so the order service compensates instead: it reserves the stock before it charges the customer, and releases the reservation again when the charge fails. Doing it the other way round would leave a customer charged for an order that was never reserved.
187+
184188
```java
185189
@RequiredArgsConstructor
186190
public class OrderService implements Service {
@@ -199,22 +203,30 @@ public class OrderService implements Service {
199203
}
200204
var stock = bus.send(new ServiceRequest(INVENTORY_SERVICE, "checkStock",
201205
Map.of("sku", sku, "quantity", quantity), credential));
202-
if (!stock.success() || !Boolean.TRUE.equals(stock.body())) {
206+
if (!stock.success()) {
207+
return ServiceResponse.error("Order rejected: " + stock.message());
208+
}
209+
if (!Boolean.TRUE.equals(stock.body())) {
203210
return ServiceResponse.error("Order rejected: insufficient stock for " + sku);
204211
}
212+
var reservation = bus.send(new ServiceRequest(INVENTORY_SERVICE, "reserve",
213+
Map.of("sku", sku, "quantity", quantity), credential));
214+
if (!reservation.success()) {
215+
return ServiceResponse.error("Order rejected: " + reservation.message());
216+
}
205217
var payment = bus.send(new ServiceRequest(PAYMENT_SERVICE, "charge",
206218
Map.of("customerId", customerId, "amount", amount), credential));
207219
if (!payment.success()) {
220+
bus.send(new ServiceRequest(INVENTORY_SERVICE, "release", // compensate the reservation
221+
Map.of("sku", sku, "quantity", quantity), credential));
208222
return ServiceResponse.error("Order rejected: " + payment.message());
209223
}
210-
bus.send(new ServiceRequest(INVENTORY_SERVICE, "reserve",
211-
Map.of("sku", sku, "quantity", quantity), credential));
212224
return ServiceResponse.ok(new OrderConfirmation(...));
213225
}
214226
}
215227
```
216228

217-
The application wires everything together behind a bus that protects the payment service and sends four requests: an order with a valid credential that succeeds, an order that fails because of stock, a request for a service nobody registered, and an anonymous order that the bus stops at the payment step.
229+
The application wires everything together behind a bus that protects the payment service and sends four requests: an order with a valid credential that succeeds, an order that fails because of stock, a request for a service nobody registered, and an anonymous order that the bus stops at the payment step, after which the order service releases the stock it had already reserved.
218230

219231
```java
220232
var registry = new ServiceRegistry();
@@ -239,36 +251,50 @@ var denied = bus.send(new ServiceRequest(OrderService.NAME, "placeOrder",
239251
Running the program produces output similar to this:
240252

241253
```
254+
Bootstrapping the service registry and the service bus
242255
Registered service 'customer' (CustomerService)
243256
Registered service 'inventory' (InventoryService)
244257
Registered service 'payment' (PaymentService)
245258
Registered service 'order' (OrderService)
246-
-> order.placeOrder payload={amount=899.0, quantity=2, sku=LAPTOP, customerId=C-1}
259+
Available services: [customer, order, inventory, payment]
260+
Placing an order with a valid credential, it should succeed
261+
-> order.placeOrder payload={amount=899.0, customerId=C-1, quantity=2, sku=LAPTOP}
247262
-> customer.getCustomer payload={customerId=C-1}
248263
<- customer.getCustomer success=true in 0 ms
249-
-> inventory.checkStock payload={quantity=2, sku=LAPTOP}
264+
-> inventory.checkStock payload={sku=LAPTOP, quantity=2}
250265
<- inventory.checkStock success=true in 0 ms
266+
-> inventory.reserve payload={sku=LAPTOP, quantity=2}
267+
<- inventory.reserve success=true in 0 ms
251268
-> payment.charge payload={amount=899.0, customerId=C-1}
252269
<- payment.charge success=true in 0 ms
253-
-> inventory.reserve payload={quantity=2, sku=LAPTOP}
254-
<- inventory.reserve success=true in 0 ms
255270
<- order.placeOrder success=true in 1 ms
256-
Order outcome: ServiceResponse[success=true, body=OrderConfirmation[orderId=ORD-1, customerName=Alice Smith, sku=LAPTOP, quantity=2, paymentReference=PAY-1], message=]
257-
...
271+
Order outcome: ServiceResponse[success=true, body=OrderConfirmation[orderId=ORD-1, customerId=C-1, customerName=Alice Smith, sku=LAPTOP, quantity=2, paymentReference=PAY-1], message=]
272+
Placing an order that should be rejected because of stock
273+
-> order.placeOrder payload={amount=499.0, customerId=C-2, quantity=50, sku=PHONE}
274+
-> customer.getCustomer payload={customerId=C-2}
275+
<- customer.getCustomer success=true in 0 ms
276+
-> inventory.checkStock payload={sku=PHONE, quantity=50}
277+
<- inventory.checkStock success=true in 0 ms
278+
<- order.placeOrder success=false in 0 ms
258279
Order outcome: ServiceResponse[success=false, body=null, message=Order rejected: insufficient stock for PHONE]
280+
Addressing a service that is not registered
259281
No service registered under 'shipping'
260282
Bus outcome: ServiceResponse[success=false, body=null, message=No such service: shipping]
261-
-> order.placeOrder payload={amount=899.0, quantity=1, sku=LAPTOP, customerId=C-1}
262-
...
283+
Placing an order anonymously, the bus should deny access to payment
284+
-> order.placeOrder payload={amount=899.0, customerId=C-1, quantity=1, sku=LAPTOP}
285+
-> customer.getCustomer payload={customerId=C-1}
286+
<- customer.getCustomer success=true in 0 ms
287+
-> inventory.checkStock payload={sku=LAPTOP, quantity=1}
288+
<- inventory.checkStock success=true in 0 ms
289+
-> inventory.reserve payload={sku=LAPTOP, quantity=1}
290+
<- inventory.reserve success=true in 0 ms
263291
Access denied to payment.charge for anonymous caller
292+
-> inventory.release payload={sku=LAPTOP, quantity=1}
293+
<- inventory.release success=true in 0 ms
264294
<- order.placeOrder success=false in 0 ms
265295
Order outcome: ServiceResponse[success=false, body=null, message=Order rejected: Access denied to payment]
266296
```
267297

268-
## Class diagram
269-
270-
See [service-oriented-architecture.urm.puml](./etc/service-oriented-architecture.urm.puml) for the PlantUML class diagram.
271-
272298
## When to Use the Service-Oriented Architecture Pattern in Java
273299

274300
* Several applications or departments need to share the same business capabilities, such as customer, billing or inventory functions.
107 KB
Loading

‎service-oriented-architecture/etc/service-oriented-architecture.urm.puml‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,9 @@ package com.iluwatar.soa {
4343
+ allows(request : ServiceRequest) : boolean
4444
}
4545
class Customer {
46-
+ Customer(id : String, name : String, email : String)
46+
+ Customer(id : String, name : String)
4747
+ id() : String
4848
+ name() : String
49-
+ email() : String
5049
}
5150
class CustomerService {
5251
+ NAME : String {static}
@@ -55,6 +54,7 @@ package com.iluwatar.soa {
5554
+ CustomerService(customers : Map<String, Customer>)
5655
+ name() : String
5756
+ handle(request : ServiceRequest) : ServiceResponse
57+
- getCustomer(customerId : String) : ServiceResponse
5858
}
5959
class InventoryService {
6060
+ NAME : String {static}
@@ -63,6 +63,9 @@ package com.iluwatar.soa {
6363
+ InventoryService(initialStock : Map<String, Integer>)
6464
+ name() : String
6565
+ handle(request : ServiceRequest) : ServiceResponse
66+
- checkStock(sku : String, quantity : int) : ServiceResponse
67+
- reserve(sku : String, quantity : int) : ServiceResponse
68+
- release(sku : String, quantity : int) : ServiceResponse
6669
}
6770
class PaymentService {
6871
+ NAME : String {static}
@@ -72,6 +75,7 @@ package com.iluwatar.soa {
7275
+ PaymentService(creditLimit : double)
7376
+ name() : String
7477
+ handle(request : ServiceRequest) : ServiceResponse
78+
- charge(customerId : String, amount : double) : ServiceResponse
7579
}
7680
class OrderService {
7781
+ NAME : String {static}
@@ -80,10 +84,12 @@ package com.iluwatar.soa {
8084
+ OrderService(bus : ServiceBus)
8185
+ name() : String
8286
+ handle(request : ServiceRequest) : ServiceResponse
87+
- placeOrder(request : ServiceRequest) : ServiceResponse
8388
}
8489
class OrderConfirmation {
85-
+ OrderConfirmation(orderId : String, customerName : String, sku : String, quantity : int, paymentReference : String)
90+
+ OrderConfirmation(orderId : String, customerId : String, customerName : String, sku : String, quantity : int, paymentReference : String)
8691
+ orderId() : String
92+
+ customerId() : String
8793
+ customerName() : String
8894
+ sku() : String
8995
+ quantity() : int

‎service-oriented-architecture/src/main/java/com/iluwatar/soa/App.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@
4343
* isolates consumers from provider failures
4444
* <li>{@link AccessPolicy}: security as a cross-cutting concern, enforced by the bus so that the
4545
* services themselves contain no security code
46-
* <li>{@link CustomerService}, {@link InventoryService}, {@link PaymentService}: stateless
47-
* enterprise services that each own one business capability
46+
* <li>{@link CustomerService}, {@link InventoryService}, {@link PaymentService}: enterprise
47+
* services that each own one business capability and keep no per-conversation session state
4848
* <li>{@link OrderService}: a composite service that orchestrates the others through the bus
4949
* </ul>
5050
*

‎service-oriented-architecture/src/main/java/com/iluwatar/soa/Customer.java‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,5 @@
2929
*
3030
* @param id the customer identifier
3131
* @param name the display name
32-
* @param email the contact address
3332
*/
34-
public record Customer(String id, String name, String email) {}
33+
public record Customer(String id, String name) {}

‎service-oriented-architecture/src/main/java/com/iluwatar/soa/CustomerService.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ public class CustomerService implements Service {
4545
public CustomerService() {
4646
this(
4747
Map.of(
48-
"C-1", new Customer("C-1", "Alice Smith", "alice@example.com"),
49-
"C-2", new Customer("C-2", "Bob Jones", "bob@example.com")));
48+
"C-1", new Customer("C-1", "Alice Smith"),
49+
"C-2", new Customer("C-2", "Bob Jones")));
5050
}
5151

5252
/** Creates the service backed by the given customers. */

‎service-oriented-architecture/src/main/java/com/iluwatar/soa/InventoryService.java‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
* available
3939
* <li>{@code reserve} with {@code sku} and {@code quantity} takes the quantity out of stock and
4040
* returns the remaining amount
41+
* <li>{@code release} with {@code sku} and {@code quantity} puts a reserved quantity back into
42+
* stock and returns the remaining amount, compensating a reservation that cannot be completed
4143
* </ul>
4244
*/
4345
public class InventoryService implements Service {
@@ -68,15 +70,23 @@ public ServiceResponse handle(ServiceRequest request) {
6870
request.param("sku", String.class), request.param("quantity", Integer.class));
6971
case "reserve" -> reserve(
7072
request.param("sku", String.class), request.param("quantity", Integer.class));
73+
case "release" -> release(
74+
request.param("sku", String.class), request.param("quantity", Integer.class));
7175
default -> ServiceResponse.error("Unknown operation: " + request.operation());
7276
};
7377
}
7478

7579
private ServiceResponse checkStock(String sku, int quantity) {
80+
if (quantity <= 0) {
81+
return ServiceResponse.error("Quantity must be positive: " + quantity);
82+
}
7683
return ServiceResponse.ok(stock.getOrDefault(sku, 0) >= quantity);
7784
}
7885

7986
private ServiceResponse reserve(String sku, int quantity) {
87+
if (quantity <= 0) {
88+
return ServiceResponse.error("Quantity must be positive: " + quantity);
89+
}
8090
var reserved = new AtomicBoolean();
8191
var remaining =
8292
stock.computeIfPresent(
@@ -99,4 +109,15 @@ private ServiceResponse reserve(String sku, int quantity) {
99109
}
100110
return ServiceResponse.ok(remaining);
101111
}
112+
113+
private ServiceResponse release(String sku, int quantity) {
114+
if (quantity <= 0) {
115+
return ServiceResponse.error("Quantity must be positive: " + quantity);
116+
}
117+
var remaining = stock.computeIfPresent(sku, (key, available) -> available + quantity);
118+
if (remaining == null) {
119+
return ServiceResponse.error("Unknown sku: " + sku);
120+
}
121+
return ServiceResponse.ok(remaining);
122+
}
102123
}

‎service-oriented-architecture/src/main/java/com/iluwatar/soa/OrderService.java‎

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737
* credential so that the bus can authorise each downstream call, and composes the responses. This
3838
* is how SOA builds higher level services out of reusable lower level ones.
3939
*
40+
* <p>There is no distributed transaction spanning the services, so the order service compensates
41+
* instead: it reserves the stock before it charges the customer and releases the reservation again
42+
* when the charge fails.
43+
*
4044
* <p>Operations:
4145
*
4246
* <ul>
@@ -60,13 +64,19 @@ public class OrderService implements Service {
6064
* The result of a successfully placed order.
6165
*
6266
* @param orderId the generated order identifier
67+
* @param customerId the identifier of the ordering customer
6368
* @param customerName the name of the ordering customer
6469
* @param sku the ordered product
6570
* @param quantity the ordered quantity
6671
* @param paymentReference the reference returned by the payment service
6772
*/
6873
public record OrderConfirmation(
69-
String orderId, String customerName, String sku, int quantity, String paymentReference) {}
74+
String orderId,
75+
String customerId,
76+
String customerName,
77+
String sku,
78+
int quantity,
79+
String paymentReference) {}
7080

7181
@Override
7282
public String name() {
@@ -96,26 +106,20 @@ private ServiceResponse placeOrder(ServiceRequest request) {
96106
return ServiceResponse.error("Order rejected: " + customer.message());
97107
}
98108

109+
// This read is kept to illustrate service composition, not as a guard: reserve re-checks the
110+
// stock atomically, so the order stays correct even though the level can change in between.
99111
var stock =
100112
bus.send(
101113
new ServiceRequest(
102114
INVENTORY_SERVICE,
103115
"checkStock",
104116
Map.of("sku", sku, "quantity", quantity),
105117
credential));
106-
if (!stock.success() || !Boolean.TRUE.equals(stock.body())) {
107-
return ServiceResponse.error("Order rejected: insufficient stock for " + sku);
118+
if (!stock.success()) {
119+
return ServiceResponse.error("Order rejected: " + stock.message());
108120
}
109-
110-
var payment =
111-
bus.send(
112-
new ServiceRequest(
113-
PAYMENT_SERVICE,
114-
"charge",
115-
Map.of("customerId", customerId, "amount", amount),
116-
credential));
117-
if (!payment.success()) {
118-
return ServiceResponse.error("Order rejected: " + payment.message());
121+
if (!Boolean.TRUE.equals(stock.body())) {
122+
return ServiceResponse.error("Order rejected: insufficient stock for " + sku);
119123
}
120124

121125
var reservation =
@@ -129,10 +133,26 @@ private ServiceResponse placeOrder(ServiceRequest request) {
129133
return ServiceResponse.error("Order rejected: " + reservation.message());
130134
}
131135

136+
var payment =
137+
bus.send(
138+
new ServiceRequest(
139+
PAYMENT_SERVICE,
140+
"charge",
141+
Map.of("customerId", customerId, "amount", amount),
142+
credential));
143+
if (!payment.success()) {
144+
bus.send(
145+
new ServiceRequest(
146+
INVENTORY_SERVICE, "release", Map.of("sku", sku, "quantity", quantity), credential));
147+
return ServiceResponse.error("Order rejected: " + payment.message());
148+
}
149+
150+
var orderingCustomer = (Customer) customer.body();
132151
var confirmation =
133152
new OrderConfirmation(
134153
"ORD-" + sequence.incrementAndGet(),
135-
((Customer) customer.body()).name(),
154+
orderingCustomer.id(),
155+
orderingCustomer.name(),
136156
sku,
137157
quantity,
138158
(String) payment.body());

0 commit comments

Comments
 (0)