diff --git a/pom.xml b/pom.xml index a71630d289d3..b1552954e1a3 100644 --- a/pom.xml +++ b/pom.xml @@ -260,6 +260,7 @@ rate-limiting-pattern fallback onion-architecture + service-oriented-architecture diff --git a/service-oriented-architecture/README.md b/service-oriented-architecture/README.md new file mode 100644 index 000000000000..44a8012a7a2a --- /dev/null +++ b/service-oriented-architecture/README.md @@ -0,0 +1,345 @@ +--- +title: "Service-Oriented Architecture Pattern in Java: Composing Reusable Enterprise Services" +shortTitle: Service-Oriented Architecture +description: "Learn the Service-Oriented Architecture (SOA) pattern in Java with a framework-free example: service contracts, a service registry, a service bus and a composite service that orchestrates the others." +category: Architectural +language: en +tag: + - Architecture + - Client-server + - Decoupling + - Enterprise patterns + - Integration + - Interface +--- + +## Also known as + +* SOA + +## Intent of Service-Oriented Architecture Design Pattern + +Structure an application as a collection of loosely coupled, reusable services that expose coarse-grained contracts and communicate through a shared bus, so that business capabilities can be discovered, composed and replaced independently of each other. + +## Detailed Explanation of Service-Oriented Architecture Pattern with Real-World Examples + +Real-world example + +> A bank runs separate departments for customer records, accounts and payments. A branch employee who opens a savings account does not walk to each department; the request goes to a central desk that knows which department handles what, forwards the paperwork, and collects the answers. Each department can change its internal procedures, or be relocated, without the branch employees noticing. + +In plain words + +> Business capabilities are packaged as independent services with published contracts, and consumers reach them through a common bus instead of calling implementations directly. + +Wikipedia says + +> Service-oriented architecture (SOA) is an architectural style that focuses on discrete services instead of a monolithic design. By consequence, it is also applied in the field of software design where services are provided to the other components by application components, through a communication protocol over a network. A service is a discrete unit of functionality that can be accessed remotely and acted upon and updated independently, such as retrieving a credit card statement online. + +Architecture diagram + +```mermaid +flowchart LR + Consumer -->|ServiceRequest| Bus[Service Bus] + Bus -->|lookup by name| Registry[Service Registry] + Bus -->|allows?| Policy[Access Policy] + Bus --> Customer[Customer Service] + Bus --> Inventory[Inventory Service] + Bus --> Payment[Payment Service] + Bus --> Order[Order Service] + Order -.->|orchestrates via bus| Bus +``` + +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. + +![Service-Oriented Architecture class diagram](./etc/service-oriented-architecture.urm.png) + +## Programmatic Example of Service-Oriented Architecture Pattern in Java + +The example builds a small order management system out of four services. It uses no framework so that the architectural roles stay visible. + +Every provider implements the same contract. The only things a consumer knows about a service are its name and the message formats. + +```java +public interface Service { + String name(); + ServiceResponse handle(ServiceRequest request); +} +``` + +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. + +```java +public record ServiceRequest( + String service, String operation, Map payload, String credential) { + public ServiceRequest(String service, String operation, Map payload) { + this(service, operation, payload, null); // anonymous caller + } + public T param(String key, Class type) { ... } +} + +public record ServiceResponse(boolean success, Object body, String message) { + public static ServiceResponse ok(Object body) { ... } + public static ServiceResponse error(String message) { ... } +} +``` + +The registry provides discovery. Services publish themselves under a name and are located at runtime, so no consumer is wired to a concrete class. + +```java +@Slf4j +public class ServiceRegistry { + private final Map services = new ConcurrentHashMap<>(); + + public void register(Service service) { + var previous = services.putIfAbsent(service.name(), service); + if (previous != null) { + throw new IllegalStateException("Service already registered: " + service.name()); + } + LOGGER.info("Registered service '{}' ({})", service.name(), service.getClass().getSimpleName()); + } + + public Optional lookup(String name) { + return Optional.ofNullable(services.get(name)); + } +} +``` + +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. + +```java +@Slf4j +public class ServiceBus { + private final ServiceRegistry registry; + private final AccessPolicy policy; + + public ServiceBus(ServiceRegistry registry, AccessPolicy policy) { ... } + + public ServiceResponse send(ServiceRequest request) { + if (!policy.allows(request)) { + LOGGER.warn("Access denied to {}.{} for {} caller", request.service(), request.operation(), + request.credential() == null ? "anonymous" : "credentialed"); + return ServiceResponse.error("Access denied to " + request.service()); + } + var service = registry.lookup(request.service()); + if (service.isEmpty()) { + return ServiceResponse.error("No such service: " + request.service()); + } + LOGGER.info("-> {}.{} payload={}", request.service(), request.operation(), request.payload()); + var start = System.nanoTime(); + try { + var response = service.get().handle(request); + LOGGER.info("<- {}.{} success={} in {} ms", request.service(), request.operation(), + response.success(), TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)); + return response; + } catch (RuntimeException e) { + return ServiceResponse.error("Service " + request.service() + " failed: " + e.getMessage()); + } + } +} +``` + +Securing services on the bus. Security is another cross-cutting concern, so it lives in the bus rather than in the services. An `AccessPolicy` names the protected services and the credentials that unlock them; a request for a protected service without a valid credential is denied before it reaches the provider, and the provider never has to know. + +```java +public class AccessPolicy { + private final Set validCredentials; + private final Set protectedServices; + + public boolean allows(ServiceRequest request) { + if (!protectedServices.contains(request.service())) { + return true; + } + return request.credential() != null && validCredentials.contains(request.credential()); + } +} + +var policy = new AccessPolicy(Set.of("checkout-service-key"), Set.of("payment")); +var bus = new ServiceBus(registry, policy); + +// anonymous call to the protected payment service +var denied = bus.send(new ServiceRequest("payment", "charge", + Map.of("customerId", "C-1", "amount", 10.0))); +// denied = ServiceResponse[success=false, body=null, message=Access denied to payment] +``` + +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. + +```java +public class CustomerService implements Service { + public static final String NAME = "customer"; + private final Map customers; + + @Override + public ServiceResponse handle(ServiceRequest request) { + return switch (request.operation()) { + case "getCustomer" -> getCustomer(request.param("customerId", String.class)); + default -> ServiceResponse.error("Unknown operation: " + request.operation()); + }; + } +} +``` + +`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. + +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. + +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. + +```java +@RequiredArgsConstructor +public class OrderService implements Service { + public static final String NAME = "order"; + private static final String CUSTOMER_SERVICE = "customer"; + private static final String INVENTORY_SERVICE = "inventory"; + private static final String PAYMENT_SERVICE = "payment"; + private final ServiceBus bus; + + private ServiceResponse placeOrder(ServiceRequest request) { + var credential = request.credential(); + var customer = bus.send(new ServiceRequest(CUSTOMER_SERVICE, "getCustomer", + Map.of("customerId", customerId), credential)); + if (!customer.success()) { + return ServiceResponse.error("Order rejected: " + customer.message()); + } + var stock = bus.send(new ServiceRequest(INVENTORY_SERVICE, "checkStock", + Map.of("sku", sku, "quantity", quantity), credential)); + if (!stock.success()) { + return ServiceResponse.error("Order rejected: " + stock.message()); + } + if (!Boolean.TRUE.equals(stock.body())) { + return ServiceResponse.error("Order rejected: insufficient stock for " + sku); + } + var reservation = bus.send(new ServiceRequest(INVENTORY_SERVICE, "reserve", + Map.of("sku", sku, "quantity", quantity), credential)); + if (!reservation.success()) { + return ServiceResponse.error("Order rejected: " + reservation.message()); + } + var payment = bus.send(new ServiceRequest(PAYMENT_SERVICE, "charge", + Map.of("customerId", customerId, "amount", amount), credential)); + if (!payment.success()) { + bus.send(new ServiceRequest(INVENTORY_SERVICE, "release", // compensate the reservation + Map.of("sku", sku, "quantity", quantity), credential)); + return ServiceResponse.error("Order rejected: " + payment.message()); + } + return ServiceResponse.ok(new OrderConfirmation(...)); + } +} +``` + +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. + +```java +var registry = new ServiceRegistry(); +var policy = new AccessPolicy(Set.of("checkout-service-key"), Set.of(PaymentService.NAME)); +var bus = new ServiceBus(registry, policy); +registry.register(new CustomerService()); +registry.register(new InventoryService()); +registry.register(new PaymentService()); +registry.register(new OrderService(bus)); + +var accepted = bus.send(new ServiceRequest(OrderService.NAME, "placeOrder", + Map.of("customerId", "C-1", "sku", "LAPTOP", "quantity", 2, "amount", 899.0), + "checkout-service-key")); +var rejected = bus.send(new ServiceRequest(OrderService.NAME, "placeOrder", + Map.of("customerId", "C-2", "sku", "PHONE", "quantity", 50, "amount", 499.0), + "checkout-service-key")); +var unknown = bus.send(new ServiceRequest("shipping", "ship", Map.of("orderId", "ORD-1"))); +var denied = bus.send(new ServiceRequest(OrderService.NAME, "placeOrder", + Map.of("customerId", "C-1", "sku", "LAPTOP", "quantity", 1, "amount", 899.0))); +``` + +Running the program produces output similar to this: + +``` +Bootstrapping the service registry and the service bus +Registered service 'customer' (CustomerService) +Registered service 'inventory' (InventoryService) +Registered service 'payment' (PaymentService) +Registered service 'order' (OrderService) +Available services: [customer, order, inventory, payment] +Placing an order with a valid credential, it should succeed +-> order.placeOrder payload={amount=899.0, customerId=C-1, quantity=2, sku=LAPTOP} +-> customer.getCustomer payload={customerId=C-1} +<- customer.getCustomer success=true in 0 ms +-> inventory.checkStock payload={sku=LAPTOP, quantity=2} +<- inventory.checkStock success=true in 0 ms +-> inventory.reserve payload={sku=LAPTOP, quantity=2} +<- inventory.reserve success=true in 0 ms +-> payment.charge payload={amount=899.0, customerId=C-1} +<- payment.charge success=true in 0 ms +<- order.placeOrder success=true in 1 ms +Order outcome: ServiceResponse[success=true, body=OrderConfirmation[orderId=ORD-1, customerId=C-1, customerName=Alice Smith, sku=LAPTOP, quantity=2, paymentReference=PAY-1], message=] +Placing an order that should be rejected because of stock +-> order.placeOrder payload={amount=499.0, customerId=C-2, quantity=50, sku=PHONE} +-> customer.getCustomer payload={customerId=C-2} +<- customer.getCustomer success=true in 0 ms +-> inventory.checkStock payload={sku=PHONE, quantity=50} +<- inventory.checkStock success=true in 0 ms +<- order.placeOrder success=false in 0 ms +Order outcome: ServiceResponse[success=false, body=null, message=Order rejected: insufficient stock for PHONE] +Addressing a service that is not registered +No service registered under 'shipping' +Bus outcome: ServiceResponse[success=false, body=null, message=No such service: shipping] +Placing an order anonymously, the bus should deny access to payment +-> order.placeOrder payload={amount=899.0, customerId=C-1, quantity=1, sku=LAPTOP} +-> customer.getCustomer payload={customerId=C-1} +<- customer.getCustomer success=true in 0 ms +-> inventory.checkStock payload={sku=LAPTOP, quantity=1} +<- inventory.checkStock success=true in 0 ms +-> inventory.reserve payload={sku=LAPTOP, quantity=1} +<- inventory.reserve success=true in 0 ms +Access denied to payment.charge for anonymous caller +-> inventory.release payload={sku=LAPTOP, quantity=1} +<- inventory.release success=true in 0 ms +<- order.placeOrder success=false in 0 ms +Order outcome: ServiceResponse[success=false, body=null, message=Order rejected: Access denied to payment] +``` + +## When to Use the Service-Oriented Architecture Pattern in Java + +* Several applications or departments need to share the same business capabilities, such as customer, billing or inventory functions. +* Systems built on different technologies must interoperate through standard, contract-first interfaces. +* Business processes are composed from existing capabilities and the composition changes more often than the capabilities themselves. +* Cross-cutting concerns such as logging, security, auditing or routing should be applied centrally rather than in every consumer. +* Providers must be replaceable or relocatable without redeploying their consumers. + +## Real-World Applications of Service-Oriented Architecture Pattern in Java + +* Enterprise service buses such as Mule ESB, Apache ServiceMix and Apache Camel that route, transform and monitor messages between services. +* SOAP web services described with WSDL and discovered through UDDI registries, the classic SOA technology stack. +* Core banking, insurance and telecom platforms that expose account, policy and billing capabilities to many channel applications. +* Java EE and Jakarta EE application servers, where JAX-WS and JAX-RS endpoints publish enterprise services. + +## Benefits and Trade-offs of Service-Oriented Architecture Pattern + +Benefits: + +* Loose coupling: consumers depend on contracts and a bus, not on implementations. +* Reusability: one service serves many consumers and many composite processes. +* Interoperability: coarse-grained, data-only messages cross language and platform boundaries. +* Central governance: discovery, routing, monitoring and security policy enforcement live in one place, so services stay free of infrastructure code. +* Independent evolution: a service can be upgraded, scaled or moved on its own. + +Trade-offs: + +* The bus and the registry are shared infrastructure that must be highly available and can become a bottleneck. +* Contract-first design adds up-front effort and message overhead compared to in-process calls. +* Coarse-grained services and centralized orchestration make individual services larger and slower to change than fine-grained microservices. +* Distributed error handling, versioning and transaction management become explicit concerns. + +## Related Java Design Patterns + +* [Microservices API Gateway](../microservices-api-gateway): a single entry point for clients; SOA differs in that its services share a common bus and are coarse-grained enterprise services, whereas microservices are fine-grained and independently deployable with no shared middleware. +* [Microservices Aggregator](../microservices-aggregrator): composes responses from several services, similar to the composite order service here. +* [Service Locator](../service-locator): the registry in this example plays the same discovery role. +* [Service Layer](../service-layer): defines an application's boundary with coarse-grained operations, the same granularity SOA services expose. +* [Business Delegate](../business-delegate): hides remote service lookup and invocation from presentation code, much like the service bus hides providers from consumers. +* [Hexagonal Architecture](../hexagonal-architecture): keeps the domain independent of its adapters; each SOA service can be structured this way internally. + +## References and Credits + +* [SOA: Principles of Service Design](https://amzn.to/3P3aHFf) by Thomas Erl +* [Service-Oriented Architecture: Analysis and Design for Services and Microservices](https://amzn.to/3RW1Z0k) by Thomas Erl +* [Service-oriented architecture (Wikipedia)](https://en.wikipedia.org/wiki/Service-oriented_architecture) +* [Pattern: Monolithic Architecture and Microservices (microservices.io)](https://microservices.io/patterns/index.html) +* [Enterprise Service Bus (Wikipedia)](https://en.wikipedia.org/wiki/Enterprise_service_bus) diff --git a/service-oriented-architecture/etc/service-oriented-architecture.urm.png b/service-oriented-architecture/etc/service-oriented-architecture.urm.png new file mode 100644 index 000000000000..436396bd0ff5 Binary files /dev/null and b/service-oriented-architecture/etc/service-oriented-architecture.urm.png differ diff --git a/service-oriented-architecture/etc/service-oriented-architecture.urm.puml b/service-oriented-architecture/etc/service-oriented-architecture.urm.puml new file mode 100644 index 000000000000..12e1589b47a9 --- /dev/null +++ b/service-oriented-architecture/etc/service-oriented-architecture.urm.puml @@ -0,0 +1,120 @@ +@startuml +package com.iluwatar.soa { + interface Service { + + name() : String {abstract} + + handle(request : ServiceRequest) : ServiceResponse {abstract} + } + class ServiceRequest { + + ServiceRequest(service : String, operation : String, payload : Map, credential : String) + + ServiceRequest(service : String, operation : String, payload : Map) + + service() : String + + operation() : String + + payload() : Map + + credential() : String + + param(key : String, type : Class) : T + } + class ServiceResponse { + + ServiceResponse(success : boolean, body : Object, message : String) + + success() : boolean + + body() : Object + + message() : String + + ok(body : Object) : ServiceResponse {static} + + error(message : String) : ServiceResponse {static} + } + class ServiceRegistry { + - services : Map + + ServiceRegistry() + + register(service : Service) : void + + lookup(name : String) : Optional + + serviceNames() : Set + } + class ServiceBus { + - registry : ServiceRegistry + - policy : AccessPolicy + + ServiceBus(registry : ServiceRegistry) + + ServiceBus(registry : ServiceRegistry, policy : AccessPolicy) + + send(request : ServiceRequest) : ServiceResponse + } + class AccessPolicy { + - validCredentials : Set + - protectedServices : Set + + AccessPolicy(validCredentials : Set, protectedServices : Set) + + permitAll() : AccessPolicy {static} + + allows(request : ServiceRequest) : boolean + } + class Customer { + + Customer(id : String, name : String) + + id() : String + + name() : String + } + class CustomerService { + + NAME : String {static} + - customers : Map + + CustomerService() + + CustomerService(customers : Map) + + name() : String + + handle(request : ServiceRequest) : ServiceResponse + - getCustomer(customerId : String) : ServiceResponse + } + class InventoryService { + + NAME : String {static} + - stock : Map + + InventoryService() + + InventoryService(initialStock : Map) + + name() : String + + handle(request : ServiceRequest) : ServiceResponse + - checkStock(sku : String, quantity : int) : ServiceResponse + - reserve(sku : String, quantity : int) : ServiceResponse + - release(sku : String, quantity : int) : ServiceResponse + } + class PaymentService { + + NAME : String {static} + - creditLimit : double + - sequence : AtomicInteger + + PaymentService() + + PaymentService(creditLimit : double) + + name() : String + + handle(request : ServiceRequest) : ServiceResponse + - charge(customerId : String, amount : double) : ServiceResponse + } + class OrderService { + + NAME : String {static} + - bus : ServiceBus + - sequence : AtomicInteger + + OrderService(bus : ServiceBus) + + name() : String + + handle(request : ServiceRequest) : ServiceResponse + - placeOrder(request : ServiceRequest) : ServiceResponse + } + class OrderConfirmation { + + OrderConfirmation(orderId : String, customerId : String, customerName : String, sku : String, quantity : int, paymentReference : String) + + orderId() : String + + customerId() : String + + customerName() : String + + sku() : String + + quantity() : int + + paymentReference() : String + } + class App { + + App() + + main(args : String[]) : void + } +} +CustomerService ..|> Service +InventoryService ..|> Service +PaymentService ..|> Service +OrderService ..|> Service +ServiceBus --> ServiceRegistry +ServiceBus --> AccessPolicy +AccessPolicy ..> ServiceRequest +ServiceRegistry --> "*" Service +OrderService --> ServiceBus +OrderService +-- OrderConfirmation +OrderService ..> OrderConfirmation +CustomerService ..> Customer +ServiceBus ..> ServiceRequest +ServiceBus ..> ServiceResponse +App ..> ServiceBus +App ..> ServiceRegistry +App ..> AccessPolicy +@enduml diff --git a/service-oriented-architecture/pom.xml b/service-oriented-architecture/pom.xml new file mode 100644 index 000000000000..74ab12b64de3 --- /dev/null +++ b/service-oriented-architecture/pom.xml @@ -0,0 +1,70 @@ + + + + 4.0.0 + + com.iluwatar + java-design-patterns + 1.26.0-SNAPSHOT + + service-oriented-architecture + + + org.slf4j + slf4j-api + + + ch.qos.logback + logback-classic + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + + + + + com.iluwatar.soa.App + + + + + + + + + diff --git a/service-oriented-architecture/src/main/java/com/iluwatar/soa/AccessPolicy.java b/service-oriented-architecture/src/main/java/com/iluwatar/soa/AccessPolicy.java new file mode 100644 index 000000000000..cac8f402e49d --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/AccessPolicy.java @@ -0,0 +1,71 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import java.util.Set; + +/** + * Decides whether a request may reach a service. + * + *

Security is a cross-cutting concern that the {@link ServiceBus} enforces centrally, so the + * services themselves stay free of security code. The model here is deliberately minimal: a set of + * valid credentials and a set of protected service names. In a real SOA this is where the bus would + * authenticate the caller and authorise the operation against a security token service. + */ +public class AccessPolicy { + + private final Set validCredentials; + private final Set protectedServices; + + /** + * Creates a policy that protects the given services and accepts the given credentials. + * + * @param validCredentials the credentials that unlock protected services + * @param protectedServices the names of the services that require a valid credential + */ + public AccessPolicy(Set validCredentials, Set protectedServices) { + this.validCredentials = Set.copyOf(validCredentials); + this.protectedServices = Set.copyOf(protectedServices); + } + + /** Creates a policy that protects nothing. */ + public static AccessPolicy permitAll() { + return new AccessPolicy(Set.of(), Set.of()); + } + + /** + * Checks whether the request may be dispatched. + * + * @param request the request about to be routed + * @return {@code true} when the target service is not protected or the request carries a valid + * credential + */ + public boolean allows(ServiceRequest request) { + if (!protectedServices.contains(request.service())) { + return true; + } + return request.credential() != null && validCredentials.contains(request.credential()); + } +} diff --git a/service-oriented-architecture/src/main/java/com/iluwatar/soa/App.java b/service-oriented-architecture/src/main/java/com/iluwatar/soa/App.java new file mode 100644 index 000000000000..316c18ae1276 --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/App.java @@ -0,0 +1,111 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; + +/** + * Service-Oriented Architecture (SOA) structures an application as a set of loosely coupled, + * reusable services that communicate through well defined, coarse-grained contracts over a shared + * communication backbone. + * + *

The building blocks demonstrated here are: + * + *

    + *
  • {@link Service}: the contract every provider implements, expressed with interoperable + * {@link ServiceRequest} and {@link ServiceResponse} messages + *
  • {@link ServiceRegistry}: discovery, so consumers locate services by name at runtime + *
  • {@link ServiceBus}: the backbone that routes messages, applies cross-cutting concerns and + * isolates consumers from provider failures + *
  • {@link AccessPolicy}: security as a cross-cutting concern, enforced by the bus so that the + * services themselves contain no security code + *
  • {@link CustomerService}, {@link InventoryService}, {@link PaymentService}: enterprise + * services that each own one business capability and keep no per-conversation session state + *
  • {@link OrderService}: a composite service that orchestrates the others through the bus + *
+ * + *

The demo registers the services behind a bus that protects the payment service, places an + * order with a valid credential that succeeds, an order that fails because of insufficient stock, + * addresses a service that does not exist, and finally places an order anonymously, which the bus + * rejects when the order service tries to charge the customer. + */ +@Slf4j +public class App { + + private static final String CHECKOUT_CREDENTIAL = "checkout-service-key"; + + /** + * Program entry point. + * + * @param args command line arguments, not used + */ + public static void main(String[] args) { + LOGGER.info("Bootstrapping the service registry and the service bus"); + var registry = new ServiceRegistry(); + var policy = new AccessPolicy(Set.of(CHECKOUT_CREDENTIAL), Set.of(PaymentService.NAME)); + var bus = new ServiceBus(registry, policy); + + registry.register(new CustomerService()); + registry.register(new InventoryService()); + registry.register(new PaymentService()); + registry.register(new OrderService(bus)); + LOGGER.info("Available services: {}", registry.serviceNames()); + + LOGGER.info("Placing an order with a valid credential, it should succeed"); + var accepted = + bus.send( + new ServiceRequest( + OrderService.NAME, + "placeOrder", + Map.of("customerId", "C-1", "sku", "LAPTOP", "quantity", 2, "amount", 899.0), + CHECKOUT_CREDENTIAL)); + LOGGER.info("Order outcome: {}", accepted); + + LOGGER.info("Placing an order that should be rejected because of stock"); + var rejected = + bus.send( + new ServiceRequest( + OrderService.NAME, + "placeOrder", + Map.of("customerId", "C-2", "sku", "PHONE", "quantity", 50, "amount", 499.0), + CHECKOUT_CREDENTIAL)); + LOGGER.info("Order outcome: {}", rejected); + + LOGGER.info("Addressing a service that is not registered"); + var unknown = bus.send(new ServiceRequest("shipping", "ship", Map.of("orderId", "ORD-1"))); + LOGGER.info("Bus outcome: {}", unknown); + + LOGGER.info("Placing an order anonymously, the bus should deny access to payment"); + var denied = + bus.send( + new ServiceRequest( + OrderService.NAME, + "placeOrder", + Map.of("customerId", "C-1", "sku", "LAPTOP", "quantity", 1, "amount", 899.0))); + LOGGER.info("Order outcome: {}", denied); + } +} diff --git a/service-oriented-architecture/src/main/java/com/iluwatar/soa/Customer.java b/service-oriented-architecture/src/main/java/com/iluwatar/soa/Customer.java new file mode 100644 index 000000000000..5615bd9409ee --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/Customer.java @@ -0,0 +1,33 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +/** + * A customer as exposed by the {@link CustomerService}. + * + * @param id the customer identifier + * @param name the display name + */ +public record Customer(String id, String name) {} diff --git a/service-oriented-architecture/src/main/java/com/iluwatar/soa/CustomerService.java b/service-oriented-architecture/src/main/java/com/iluwatar/soa/CustomerService.java new file mode 100644 index 000000000000..88b773f899ab --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/CustomerService.java @@ -0,0 +1,76 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import java.util.Map; + +/** + * Enterprise service that owns customer data. + * + *

Operations: + * + *

    + *
  • {@code getCustomer} with parameter {@code customerId} returns a {@link Customer} + *
+ */ +public class CustomerService implements Service { + + public static final String NAME = "customer"; + + private final Map customers; + + /** Creates the service with a small in-memory customer base. */ + public CustomerService() { + this( + Map.of( + "C-1", new Customer("C-1", "Alice Smith"), + "C-2", new Customer("C-2", "Bob Jones"))); + } + + /** Creates the service backed by the given customers. */ + public CustomerService(Map customers) { + this.customers = Map.copyOf(customers); + } + + @Override + public String name() { + return NAME; + } + + @Override + public ServiceResponse handle(ServiceRequest request) { + return switch (request.operation()) { + case "getCustomer" -> getCustomer(request.param("customerId", String.class)); + default -> ServiceResponse.error("Unknown operation: " + request.operation()); + }; + } + + private ServiceResponse getCustomer(String customerId) { + var customer = customers.get(customerId); + return customer == null + ? ServiceResponse.error("Unknown customer: " + customerId) + : ServiceResponse.ok(customer); + } +} diff --git a/service-oriented-architecture/src/main/java/com/iluwatar/soa/InventoryService.java b/service-oriented-architecture/src/main/java/com/iluwatar/soa/InventoryService.java new file mode 100644 index 000000000000..7b02ed9b924b --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/InventoryService.java @@ -0,0 +1,123 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Enterprise service that owns stock levels. + * + *

Operations: + * + *

    + *
  • {@code checkStock} with {@code sku} and {@code quantity} returns whether the quantity is + * available + *
  • {@code reserve} with {@code sku} and {@code quantity} takes the quantity out of stock and + * returns the remaining amount + *
  • {@code release} with {@code sku} and {@code quantity} puts a reserved quantity back into + * stock and returns the remaining amount, compensating a reservation that cannot be completed + *
+ */ +public class InventoryService implements Service { + + public static final String NAME = "inventory"; + + private final Map stock = new ConcurrentHashMap<>(); + + /** Creates the service with a small in-memory stock. */ + public InventoryService() { + this(Map.of("LAPTOP", 5, "PHONE", 10)); + } + + /** Creates the service with the given initial stock levels. */ + public InventoryService(Map initialStock) { + stock.putAll(initialStock); + } + + @Override + public String name() { + return NAME; + } + + @Override + public ServiceResponse handle(ServiceRequest request) { + return switch (request.operation()) { + case "checkStock" -> checkStock( + request.param("sku", String.class), request.param("quantity", Integer.class)); + case "reserve" -> reserve( + request.param("sku", String.class), request.param("quantity", Integer.class)); + case "release" -> release( + request.param("sku", String.class), request.param("quantity", Integer.class)); + default -> ServiceResponse.error("Unknown operation: " + request.operation()); + }; + } + + private ServiceResponse checkStock(String sku, int quantity) { + if (quantity <= 0) { + return ServiceResponse.error("Quantity must be positive: " + quantity); + } + return ServiceResponse.ok(stock.getOrDefault(sku, 0) >= quantity); + } + + private ServiceResponse reserve(String sku, int quantity) { + if (quantity <= 0) { + return ServiceResponse.error("Quantity must be positive: " + quantity); + } + var reserved = new AtomicBoolean(); + var remaining = + stock.computeIfPresent( + sku, + (key, available) -> { + if (available >= quantity) { + reserved.set(true); + return available - quantity; + } + return available; + }); + if (!reserved.get()) { + return ServiceResponse.error( + "Insufficient stock for " + + sku + + ": requested " + + quantity + + ", available " + + (remaining == null ? 0 : remaining)); + } + return ServiceResponse.ok(remaining); + } + + private ServiceResponse release(String sku, int quantity) { + if (quantity <= 0) { + return ServiceResponse.error("Quantity must be positive: " + quantity); + } + var remaining = stock.computeIfPresent(sku, (key, available) -> available + quantity); + if (remaining == null) { + return ServiceResponse.error("Unknown sku: " + sku); + } + return ServiceResponse.ok(remaining); + } +} diff --git a/service-oriented-architecture/src/main/java/com/iluwatar/soa/OrderService.java b/service-oriented-architecture/src/main/java/com/iluwatar/soa/OrderService.java new file mode 100644 index 000000000000..6f9b4d7ea61c --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/OrderService.java @@ -0,0 +1,161 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.RequiredArgsConstructor; + +/** + * Composite service that orchestrates the customer, inventory and payment services into one + * business capability: placing an order. + * + *

The order service depends only on service names and message contracts, never on the classes + * that provide them. It sends messages through the {@link ServiceBus}, forwards the caller's + * credential so that the bus can authorise each downstream call, and composes the responses. This + * is how SOA builds higher level services out of reusable lower level ones. + * + *

There is no distributed transaction spanning 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. + * + *

Operations: + * + *

    + *
  • {@code placeOrder} with {@code customerId}, {@code sku}, {@code quantity} and {@code + * amount} returns an {@link OrderConfirmation} + *
+ */ +@RequiredArgsConstructor +public class OrderService implements Service { + + public static final String NAME = "order"; + + private static final String CUSTOMER_SERVICE = "customer"; + private static final String INVENTORY_SERVICE = "inventory"; + private static final String PAYMENT_SERVICE = "payment"; + + private final ServiceBus bus; + private final AtomicInteger sequence = new AtomicInteger(); + + /** + * The result of a successfully placed order. + * + * @param orderId the generated order identifier + * @param customerId the identifier of the ordering customer + * @param customerName the name of the ordering customer + * @param sku the ordered product + * @param quantity the ordered quantity + * @param paymentReference the reference returned by the payment service + */ + public record OrderConfirmation( + String orderId, + String customerId, + String customerName, + String sku, + int quantity, + String paymentReference) {} + + @Override + public String name() { + return NAME; + } + + @Override + public ServiceResponse handle(ServiceRequest request) { + return switch (request.operation()) { + case "placeOrder" -> placeOrder(request); + default -> ServiceResponse.error("Unknown operation: " + request.operation()); + }; + } + + private ServiceResponse placeOrder(ServiceRequest request) { + var customerId = request.param("customerId", String.class); + var sku = request.param("sku", String.class); + var quantity = request.param("quantity", Integer.class); + var amount = request.param("amount", Double.class); + var credential = request.credential(); + + var customer = + bus.send( + new ServiceRequest( + CUSTOMER_SERVICE, "getCustomer", Map.of("customerId", customerId), credential)); + if (!customer.success()) { + return ServiceResponse.error("Order rejected: " + customer.message()); + } + + // This read is kept to illustrate service composition, not as a guard: reserve re-checks the + // stock atomically, so the order stays correct even though the level can change in between. + var stock = + bus.send( + new ServiceRequest( + INVENTORY_SERVICE, + "checkStock", + Map.of("sku", sku, "quantity", quantity), + credential)); + if (!stock.success()) { + return ServiceResponse.error("Order rejected: " + stock.message()); + } + if (!Boolean.TRUE.equals(stock.body())) { + return ServiceResponse.error("Order rejected: insufficient stock for " + sku); + } + + var reservation = + bus.send( + new ServiceRequest( + INVENTORY_SERVICE, + "reserve", + Map.of("sku", sku, "quantity", quantity), + credential)); + if (!reservation.success()) { + return ServiceResponse.error("Order rejected: " + reservation.message()); + } + + var payment = + bus.send( + new ServiceRequest( + PAYMENT_SERVICE, + "charge", + Map.of("customerId", customerId, "amount", amount), + credential)); + if (!payment.success()) { + bus.send( + new ServiceRequest( + INVENTORY_SERVICE, "release", Map.of("sku", sku, "quantity", quantity), credential)); + return ServiceResponse.error("Order rejected: " + payment.message()); + } + + var orderingCustomer = (Customer) customer.body(); + var confirmation = + new OrderConfirmation( + "ORD-" + sequence.incrementAndGet(), + orderingCustomer.id(), + orderingCustomer.name(), + sku, + quantity, + (String) payment.body()); + return ServiceResponse.ok(confirmation); + } +} diff --git a/service-oriented-architecture/src/main/java/com/iluwatar/soa/PaymentService.java b/service-oriented-architecture/src/main/java/com/iluwatar/soa/PaymentService.java new file mode 100644 index 000000000000..4ffd3a034e75 --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/PaymentService.java @@ -0,0 +1,80 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Enterprise service that charges customers. + * + *

Operations: + * + *

    + *
  • {@code charge} with {@code customerId} and {@code amount} returns a payment reference, or + * fails when the amount exceeds the credit limit + *
+ */ +public class PaymentService implements Service { + + public static final String NAME = "payment"; + + private final double creditLimit; + private final AtomicInteger sequence = new AtomicInteger(); + + /** Creates the service with a default credit limit. */ + public PaymentService() { + this(1000.0); + } + + /** Creates the service that declines any charge above the given limit. */ + public PaymentService(double creditLimit) { + this.creditLimit = creditLimit; + } + + @Override + public String name() { + return NAME; + } + + @Override + public ServiceResponse handle(ServiceRequest request) { + return switch (request.operation()) { + case "charge" -> charge( + request.param("customerId", String.class), request.param("amount", Double.class)); + default -> ServiceResponse.error("Unknown operation: " + request.operation()); + }; + } + + private ServiceResponse charge(String customerId, double amount) { + if (amount <= 0) { + return ServiceResponse.error("Amount must be positive: " + amount); + } + if (amount > creditLimit) { + return ServiceResponse.error( + "Payment of " + amount + " declined for " + customerId + ": exceeds credit limit"); + } + return ServiceResponse.ok("PAY-" + sequence.incrementAndGet()); + } +} diff --git a/service-oriented-architecture/src/main/java/com/iluwatar/soa/Service.java b/service-oriented-architecture/src/main/java/com/iluwatar/soa/Service.java new file mode 100644 index 000000000000..3a7c1bd39f59 --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/Service.java @@ -0,0 +1,49 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +/** + * The service contract every provider exposes to the {@link ServiceBus}. + * + *

In a service-oriented architecture a service is a coarse-grained, stateless unit of business + * functionality. Consumers never depend on the implementation class; they only know the service + * name and the message formats ({@link ServiceRequest} and {@link ServiceResponse}). This keeps + * services loosely coupled and independently replaceable. + */ +public interface Service { + + /** The unique name consumers use to address this service through the bus. */ + String name(); + + /** + * Handles a single request. Implementations hold no per-conversation session state: every request + * carries everything the service needs to process it, and any state a service owns is shared + * between callers and guarded for concurrent access. + * + * @param request the incoming message + * @return the outcome of the operation + */ + ServiceResponse handle(ServiceRequest request); +} diff --git a/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceBus.java b/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceBus.java new file mode 100644 index 000000000000..f63c98d9bcf2 --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceBus.java @@ -0,0 +1,94 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; + +/** + * The service bus is the communication backbone of the architecture. + * + *

Consumers hand every request to the bus. The bus applies cross-cutting concerns in one place + * (access control through the {@link AccessPolicy}, tracing and timing), discovers the target + * service in the {@link ServiceRegistry} and shields consumers from provider failures by + * translating exceptions into error responses. Access control comes first, so a caller that may not + * reach a service learns nothing about what the registry holds. Because the consumer only talks to + * the bus, the provider can be replaced or relocated transparently. + */ +@Slf4j +public class ServiceBus { + + private final ServiceRegistry registry; + private final AccessPolicy policy; + + /** Creates a bus that routes to the given registry and lets every request through. */ + public ServiceBus(ServiceRegistry registry) { + this(registry, AccessPolicy.permitAll()); + } + + /** Creates a bus that routes to the given registry and enforces the given access policy. */ + public ServiceBus(ServiceRegistry registry, AccessPolicy policy) { + this.registry = registry; + this.policy = policy; + } + + /** + * Routes the request to the service it addresses. + * + * @param request the message to deliver + * @return the service response, or an error response when the service is unknown, access is + * denied or the service fails + */ + public ServiceResponse send(ServiceRequest request) { + if (!policy.allows(request)) { + LOGGER.warn( + "Access denied to {}.{} for {} caller", + request.service(), + request.operation(), + request.credential() == null ? "anonymous" : "credentialed"); + return ServiceResponse.error("Access denied to " + request.service()); + } + var service = registry.lookup(request.service()); + if (service.isEmpty()) { + LOGGER.warn("No service registered under '{}'", request.service()); + return ServiceResponse.error("No such service: " + request.service()); + } + LOGGER.info("-> {}.{} payload={}", request.service(), request.operation(), request.payload()); + var start = System.nanoTime(); + try { + var response = service.get().handle(request); + LOGGER.info( + "<- {}.{} success={} in {} ms", + request.service(), + request.operation(), + response.success(), + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)); + return response; + } catch (RuntimeException e) { + LOGGER.error("<- {}.{} failed: {}", request.service(), request.operation(), e.getMessage()); + return ServiceResponse.error("Service " + request.service() + " failed: " + e.getMessage()); + } + } +} diff --git a/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceRegistry.java b/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceRegistry.java new file mode 100644 index 000000000000..883e32f49242 --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceRegistry.java @@ -0,0 +1,68 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import lombok.extern.slf4j.Slf4j; + +/** + * The service registry provides discovery: services publish themselves under a name and consumers + * locate them at runtime instead of hard-wiring implementations. + * + *

The registry is the only place that knows which concrete class provides a contract, so a + * service can be swapped or moved without touching its consumers. + */ +@Slf4j +public class ServiceRegistry { + + private final Map services = new ConcurrentHashMap<>(); + + /** + * Publishes a service under its name. + * + * @param service the service to publish + * @throws IllegalStateException when another service already uses the same name + */ + public void register(Service service) { + var previous = services.putIfAbsent(service.name(), service); + if (previous != null) { + throw new IllegalStateException("Service already registered: " + service.name()); + } + LOGGER.info("Registered service '{}' ({})", service.name(), service.getClass().getSimpleName()); + } + + /** Finds a service by name. */ + public Optional lookup(String name) { + return Optional.ofNullable(services.get(name)); + } + + /** The names of all published services. */ + public Set serviceNames() { + return Set.copyOf(services.keySet()); + } +} diff --git a/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceRequest.java b/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceRequest.java new file mode 100644 index 000000000000..f27953a6fb52 --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceRequest.java @@ -0,0 +1,72 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import java.util.Map; + +/** + * A coarse-grained, self-describing message sent to a service through the {@link ServiceBus}. + * + *

The request names the target service and the operation to invoke, carries a flat, immutable + * payload and optionally the caller's credential. Because the request payload is plain data rather + * than typed Java objects, the same contract could be transported as SOAP, JSON or any other + * interoperable format. Responses in this demo carry typed Java objects for brevity; a deployed + * system would describe them as plain data too. + * + * @param service the name of the target service + * @param operation the operation the target service should perform + * @param payload the parameters of the operation + * @param credential the caller's credential, {@code null} for an anonymous caller + */ +public record ServiceRequest( + String service, String operation, Map payload, String credential) { + + public ServiceRequest { + payload = Map.copyOf(payload); + } + + /** Creates an anonymous request. */ + public ServiceRequest(String service, String operation, Map payload) { + this(service, operation, payload, null); + } + + /** + * Reads a typed parameter from the payload. + * + * @param key the parameter name + * @param type the expected type + * @param the expected type + * @return the parameter value + * @throws IllegalArgumentException when the parameter is missing or has another type + */ + public T param(String key, Class type) { + var value = payload.get(key); + if (!type.isInstance(value)) { + throw new IllegalArgumentException( + "Missing or invalid parameter '" + key + "' for operation '" + operation + "'"); + } + return type.cast(value); + } +} diff --git a/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceResponse.java b/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceResponse.java new file mode 100644 index 000000000000..739a38285ba4 --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceResponse.java @@ -0,0 +1,48 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +/** + * The message a service returns through the {@link ServiceBus}. + * + *

Every operation answers with the same envelope so that consumers and the bus can treat all + * services uniformly: a success flag, an optional body and a human readable message. + * + * @param success whether the operation completed + * @param body the result of a successful operation, may be {@code null} + * @param message a description of the failure, empty on success + */ +public record ServiceResponse(boolean success, Object body, String message) { + + /** Creates a successful response carrying the given body. */ + public static ServiceResponse ok(Object body) { + return new ServiceResponse(true, body, ""); + } + + /** Creates a failed response with the given explanation. */ + public static ServiceResponse error(String message) { + return new ServiceResponse(false, null, message); + } +} diff --git a/service-oriented-architecture/src/test/java/com/iluwatar/soa/AccessPolicyTest.java b/service-oriented-architecture/src/test/java/com/iluwatar/soa/AccessPolicyTest.java new file mode 100644 index 000000000000..22d0e809918a --- /dev/null +++ b/service-oriented-architecture/src/test/java/com/iluwatar/soa/AccessPolicyTest.java @@ -0,0 +1,69 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class AccessPolicyTest { + + private final AccessPolicy policy = new AccessPolicy(Set.of("secret"), Set.of("payment")); + + @Test + void permitAllShouldAllowEverything() { + var permitAll = AccessPolicy.permitAll(); + + assertTrue(permitAll.allows(request("payment", null))); + assertTrue(permitAll.allows(request("customer", null))); + } + + @Test + void shouldDenyProtectedServiceWithoutCredential() { + assertFalse(policy.allows(request("payment", null))); + } + + @Test + void shouldDenyProtectedServiceWithUnknownCredential() { + assertFalse(policy.allows(request("payment", "wrong"))); + } + + @Test + void shouldAllowProtectedServiceWithValidCredential() { + assertTrue(policy.allows(request("payment", "secret"))); + } + + @Test + void shouldAllowUnprotectedServiceAnonymously() { + assertTrue(policy.allows(request("customer", null))); + } + + private static ServiceRequest request(String service, String credential) { + return new ServiceRequest(service, "op", Map.of(), credential); + } +} diff --git a/service-oriented-architecture/src/test/java/com/iluwatar/soa/AppTest.java b/service-oriented-architecture/src/test/java/com/iluwatar/soa/AppTest.java new file mode 100644 index 000000000000..b2105dfe02b4 --- /dev/null +++ b/service-oriented-architecture/src/test/java/com/iluwatar/soa/AppTest.java @@ -0,0 +1,43 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +class AppTest { + + @Test + void shouldLaunchApp() { + assertDoesNotThrow(() -> App.main(new String[] {})); + } + + @Test + void shouldBeInstantiable() { + assertNotNull(new App(), "App should be instantiable"); + } +} diff --git a/service-oriented-architecture/src/test/java/com/iluwatar/soa/CustomerServiceTest.java b/service-oriented-architecture/src/test/java/com/iluwatar/soa/CustomerServiceTest.java new file mode 100644 index 000000000000..b4e84a57e247 --- /dev/null +++ b/service-oriented-architecture/src/test/java/com/iluwatar/soa/CustomerServiceTest.java @@ -0,0 +1,65 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class CustomerServiceTest { + + private final CustomerService service = new CustomerService(); + + @Test + void shouldReturnKnownCustomer() { + var response = + service.handle( + new ServiceRequest(CustomerService.NAME, "getCustomer", Map.of("customerId", "C-2"))); + + assertTrue(response.success()); + assertEquals(new Customer("C-2", "Bob Jones"), response.body()); + } + + @Test + void shouldFailForUnknownCustomer() { + var response = + service.handle( + new ServiceRequest(CustomerService.NAME, "getCustomer", Map.of("customerId", "C-9"))); + + assertFalse(response.success()); + assertEquals("Unknown customer: C-9", response.message()); + } + + @Test + void shouldFailForUnknownOperation() { + var response = service.handle(new ServiceRequest(CustomerService.NAME, "delete", Map.of())); + + assertFalse(response.success()); + assertEquals("Unknown operation: delete", response.message()); + } +} diff --git a/service-oriented-architecture/src/test/java/com/iluwatar/soa/InventoryServiceTest.java b/service-oriented-architecture/src/test/java/com/iluwatar/soa/InventoryServiceTest.java new file mode 100644 index 000000000000..774501705b76 --- /dev/null +++ b/service-oriented-architecture/src/test/java/com/iluwatar/soa/InventoryServiceTest.java @@ -0,0 +1,132 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class InventoryServiceTest { + + private final InventoryService service = new InventoryService(Map.of("LAPTOP", 3)); + + @Test + void shouldReportAvailability() { + assertEquals(true, check("LAPTOP", 3).body()); + assertEquals(false, check("LAPTOP", 4).body()); + assertEquals(false, check("TABLET", 1).body()); + } + + @Test + void shouldReserveAndReduceStock() { + var response = reserve("LAPTOP", 2); + + assertTrue(response.success()); + assertEquals(1, response.body()); + assertEquals(false, check("LAPTOP", 2).body()); + } + + @Test + void shouldRejectReservationBeyondStock() { + var response = reserve("LAPTOP", 5); + + assertFalse(response.success()); + assertEquals("Insufficient stock for LAPTOP: requested 5, available 3", response.message()); + assertEquals(true, check("LAPTOP", 3).body()); + } + + @Test + void shouldRejectReservationOfUnknownSku() { + var response = reserve("TABLET", 1); + + assertFalse(response.success()); + assertEquals("Insufficient stock for TABLET: requested 1, available 0", response.message()); + } + + @Test + void shouldReleaseReservedStock() { + reserve("LAPTOP", 2); + + var response = release("LAPTOP", 2); + + assertTrue(response.success()); + assertEquals(3, response.body()); + assertEquals(true, check("LAPTOP", 3).body()); + } + + @Test + void shouldRejectReleaseOfUnknownSku() { + var response = release("TABLET", 1); + + assertFalse(response.success()); + assertEquals("Unknown sku: TABLET", response.message()); + } + + @Test + void shouldRejectNonPositiveQuantity() { + assertRejected(check("LAPTOP", 0), 0); + assertRejected(check("LAPTOP", -5), -5); + assertRejected(reserve("LAPTOP", 0), 0); + assertRejected(reserve("LAPTOP", -5), -5); + assertRejected(release("LAPTOP", 0), 0); + assertRejected(release("LAPTOP", -5), -5); + assertEquals(true, check("LAPTOP", 3).body()); + assertEquals(false, check("LAPTOP", 4).body()); + } + + @Test + void shouldFailForUnknownOperation() { + var response = service.handle(new ServiceRequest(InventoryService.NAME, "audit", Map.of())); + + assertFalse(response.success()); + assertEquals("Unknown operation: audit", response.message()); + } + + private ServiceResponse check(String sku, int quantity) { + return service.handle( + new ServiceRequest( + InventoryService.NAME, "checkStock", Map.of("sku", sku, "quantity", quantity))); + } + + private ServiceResponse reserve(String sku, int quantity) { + return service.handle( + new ServiceRequest( + InventoryService.NAME, "reserve", Map.of("sku", sku, "quantity", quantity))); + } + + private ServiceResponse release(String sku, int quantity) { + return service.handle( + new ServiceRequest( + InventoryService.NAME, "release", Map.of("sku", sku, "quantity", quantity))); + } + + private static void assertRejected(ServiceResponse response, int quantity) { + assertFalse(response.success()); + assertEquals("Quantity must be positive: " + quantity, response.message()); + } +} diff --git a/service-oriented-architecture/src/test/java/com/iluwatar/soa/OrderServiceTest.java b/service-oriented-architecture/src/test/java/com/iluwatar/soa/OrderServiceTest.java new file mode 100644 index 000000000000..add4650e3cb8 --- /dev/null +++ b/service-oriented-architecture/src/test/java/com/iluwatar/soa/OrderServiceTest.java @@ -0,0 +1,260 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class OrderServiceTest { + + private static final String CREDENTIAL = "checkout-key"; + + private ServiceBus bus; + private InventoryService inventory; + + @BeforeEach + void setUp() { + var registry = new ServiceRegistry(); + bus = + new ServiceBus(registry, new AccessPolicy(Set.of(CREDENTIAL), Set.of(PaymentService.NAME))); + inventory = new InventoryService(Map.of("LAPTOP", 2)); + registry.register(new CustomerService()); + registry.register(inventory); + registry.register(new PaymentService(1000.0)); + registry.register(new OrderService(bus)); + } + + @Test + void shouldPlaceOrderAndReserveStock() { + var response = placeOrder("C-1", "LAPTOP", 2, 899.0, CREDENTIAL); + + assertTrue(response.success()); + var confirmation = (OrderService.OrderConfirmation) response.body(); + assertEquals("ORD-1", confirmation.orderId()); + assertEquals("C-1", confirmation.customerId()); + assertEquals("Alice Smith", confirmation.customerName()); + assertEquals("LAPTOP", confirmation.sku()); + assertEquals(2, confirmation.quantity()); + assertEquals("PAY-1", confirmation.paymentReference()); + assertEquals(false, checkStock("LAPTOP", 1)); + } + + @Test + void shouldRejectOrderForUnknownCustomer() { + var response = placeOrder("C-9", "LAPTOP", 1, 899.0, CREDENTIAL); + + assertFalse(response.success()); + assertEquals("Order rejected: Unknown customer: C-9", response.message()); + assertEquals(true, checkStock("LAPTOP", 2)); + } + + @Test + void shouldRejectOrderWhenStockIsInsufficient() { + var response = placeOrder("C-1", "LAPTOP", 3, 899.0, CREDENTIAL); + + assertFalse(response.success()); + assertEquals("Order rejected: insufficient stock for LAPTOP", response.message()); + assertEquals(true, checkStock("LAPTOP", 2)); + } + + @Test + void shouldRejectOrderWhenPaymentIsDeclined() { + var response = placeOrder("C-1", "LAPTOP", 1, 1500.0, CREDENTIAL); + + assertFalse(response.success()); + assertEquals( + "Order rejected: Payment of 1500.0 declined for C-1: exceeds credit limit", + response.message()); + assertEquals(true, checkStock("LAPTOP", 2)); + } + + @Test + void shouldRejectOrderWhenCallerLacksPaymentCredential() { + var response = placeOrder("C-1", "LAPTOP", 1, 899.0, null); + + assertFalse(response.success()); + assertEquals("Order rejected: Access denied to payment", response.message()); + assertEquals(true, checkStock("LAPTOP", 2)); + } + + @Test + void shouldReserveBeforeChargingAndReleaseWhenPaymentFails() { + var operations = new ArrayList(); + var recordingBus = busWithRecordingInventory(operations); + + var response = placeOrder(recordingBus, "C-1", "LAPTOP", 2, 1500.0, CREDENTIAL); + + assertFalse(response.success()); + assertEquals( + "Order rejected: Payment of 1500.0 declined for C-1: exceeds credit limit", + response.message()); + assertEquals(List.of("checkStock", "reserve", "release"), operations); + assertEquals(true, checkStock("LAPTOP", 2)); + } + + @Test + void shouldRejectOrderWhenReservationFails() { + var charges = new AtomicInteger(); + var stubBus = + busWithInventoryStub( + ServiceResponse.ok(true), ServiceResponse.error("reservation failed"), charges); + + var response = placeOrder(stubBus, "C-1", "LAPTOP", 1, 899.0, CREDENTIAL); + + assertFalse(response.success()); + assertEquals("Order rejected: reservation failed", response.message()); + assertEquals(0, charges.get()); + } + + @Test + void shouldRejectOrderWhenStockCheckFails() { + var charges = new AtomicInteger(); + var stubBus = + busWithInventoryStub( + ServiceResponse.error("inventory unavailable"), ServiceResponse.ok(0), charges); + + var response = placeOrder(stubBus, "C-1", "LAPTOP", 1, 899.0, CREDENTIAL); + + assertFalse(response.success()); + assertEquals("Order rejected: inventory unavailable", response.message()); + assertEquals(0, charges.get()); + } + + @Test + void shouldRejectNonPositiveQuantityWithTheInventoryReason() { + var response = placeOrder("C-1", "LAPTOP", -5, 899.0, CREDENTIAL); + + assertFalse(response.success()); + assertEquals("Order rejected: Quantity must be positive: -5", response.message()); + assertEquals(true, checkStock("LAPTOP", 2)); + } + + @Test + void shouldFailForUnknownOperation() { + var response = bus.send(new ServiceRequest(OrderService.NAME, "cancelOrder", Map.of())); + + assertFalse(response.success()); + assertEquals("Unknown operation: cancelOrder", response.message()); + } + + private static ServiceBus busWithInventoryStub( + ServiceResponse checkStockResponse, ServiceResponse reserveResponse, AtomicInteger charges) { + var registry = new ServiceRegistry(); + var stubBus = + new ServiceBus(registry, new AccessPolicy(Set.of(CREDENTIAL), Set.of(PaymentService.NAME))); + registry.register(new CustomerService()); + registry.register(countingPaymentService(charges)); + registry.register(new OrderService(stubBus)); + registry.register( + new Service() { + @Override + public String name() { + return InventoryService.NAME; + } + + @Override + public ServiceResponse handle(ServiceRequest request) { + return "checkStock".equals(request.operation()) ? checkStockResponse : reserveResponse; + } + }); + return stubBus; + } + + private static Service countingPaymentService(AtomicInteger charges) { + var payment = new PaymentService(1000.0); + return new Service() { + @Override + public String name() { + return PaymentService.NAME; + } + + @Override + public ServiceResponse handle(ServiceRequest request) { + charges.incrementAndGet(); + return payment.handle(request); + } + }; + } + + private ServiceBus busWithRecordingInventory(List operations) { + var registry = new ServiceRegistry(); + var recordingBus = + new ServiceBus(registry, new AccessPolicy(Set.of(CREDENTIAL), Set.of(PaymentService.NAME))); + registry.register(new CustomerService()); + registry.register(new PaymentService(1000.0)); + registry.register(new OrderService(recordingBus)); + registry.register( + new Service() { + @Override + public String name() { + return InventoryService.NAME; + } + + @Override + public ServiceResponse handle(ServiceRequest request) { + operations.add(request.operation()); + return inventory.handle(request); + } + }); + return recordingBus; + } + + private ServiceResponse placeOrder( + String customerId, String sku, int quantity, double amount, String credential) { + return placeOrder(bus, customerId, sku, quantity, amount, credential); + } + + private static ServiceResponse placeOrder( + ServiceBus target, + String customerId, + String sku, + int quantity, + double amount, + String credential) { + return target.send( + new ServiceRequest( + OrderService.NAME, + "placeOrder", + Map.of("customerId", customerId, "sku", sku, "quantity", quantity, "amount", amount), + credential)); + } + + private Object checkStock(String sku, int quantity) { + return inventory + .handle( + new ServiceRequest( + InventoryService.NAME, "checkStock", Map.of("sku", sku, "quantity", quantity))) + .body(); + } +} diff --git a/service-oriented-architecture/src/test/java/com/iluwatar/soa/PaymentServiceTest.java b/service-oriented-architecture/src/test/java/com/iluwatar/soa/PaymentServiceTest.java new file mode 100644 index 000000000000..0b5c2efe0a07 --- /dev/null +++ b/service-oriented-architecture/src/test/java/com/iluwatar/soa/PaymentServiceTest.java @@ -0,0 +1,78 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class PaymentServiceTest { + + private final PaymentService service = new PaymentService(100.0); + + @Test + void shouldChargeWithinCreditLimitAndIssueUniqueReferences() { + var first = charge(60.0); + var second = charge(100.0); + + assertTrue(first.success()); + assertTrue(second.success()); + assertEquals("PAY-1", first.body()); + assertEquals("PAY-2", second.body()); + } + + @Test + void shouldDeclineChargeAboveCreditLimit() { + var response = charge(100.5); + + assertFalse(response.success()); + assertEquals("Payment of 100.5 declined for C-1: exceeds credit limit", response.message()); + } + + @Test + void shouldDeclineNonPositiveAmount() { + var response = charge(0.0); + + assertFalse(response.success()); + assertEquals("Amount must be positive: 0.0", response.message()); + } + + @Test + void shouldFailForUnknownOperation() { + var response = service.handle(new ServiceRequest(PaymentService.NAME, "refund", Map.of())); + + assertFalse(response.success()); + assertEquals("Unknown operation: refund", response.message()); + } + + private ServiceResponse charge(double amount) { + return service.handle( + new ServiceRequest( + PaymentService.NAME, "charge", Map.of("customerId", "C-1", "amount", amount))); + } +} diff --git a/service-oriented-architecture/src/test/java/com/iluwatar/soa/ServiceBusTest.java b/service-oriented-architecture/src/test/java/com/iluwatar/soa/ServiceBusTest.java new file mode 100644 index 000000000000..0e5d1dd519dd --- /dev/null +++ b/service-oriented-architecture/src/test/java/com/iluwatar/soa/ServiceBusTest.java @@ -0,0 +1,144 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ServiceBusTest { + + private ServiceRegistry registry; + private ServiceBus bus; + + @BeforeEach + void setUp() { + registry = new ServiceRegistry(); + bus = new ServiceBus(registry); + } + + @Test + void shouldRouteRequestToRegisteredServiceWithoutAlteringResponse() { + var customerService = new CustomerService(); + registry.register(customerService); + var request = + new ServiceRequest(CustomerService.NAME, "getCustomer", Map.of("customerId", "C-1")); + + var viaBus = bus.send(request); + var direct = customerService.handle(request); + + assertEquals(direct, viaBus); + assertTrue(viaBus.success()); + assertEquals("Alice Smith", ((Customer) viaBus.body()).name()); + } + + @Test + void shouldReturnErrorForUnknownService() { + var response = bus.send(new ServiceRequest("shipping", "ship", Map.of())); + + assertFalse(response.success()); + assertEquals("No such service: shipping", response.message()); + } + + @Test + void shouldTranslateServiceExceptionIntoErrorResponse() { + registry.register(new CustomerService()); + + var response = bus.send(new ServiceRequest(CustomerService.NAME, "getCustomer", Map.of())); + + assertFalse(response.success()); + assertTrue(response.message().startsWith("Service customer failed:")); + assertTrue(response.message().contains("customerId")); + } + + @Test + void shouldDenyProtectedServiceWithoutInvokingIt() { + var invocations = new AtomicInteger(); + registry.register(countingService(invocations)); + var securedBus = new ServiceBus(registry, new AccessPolicy(Set.of("key"), Set.of("counter"))); + + var response = securedBus.send(new ServiceRequest("counter", "count", Map.of())); + + assertFalse(response.success()); + assertEquals("Access denied to counter", response.message()); + assertEquals(0, invocations.get()); + } + + @Test + void shouldDenyProtectedServiceWithWrongCredential() { + var invocations = new AtomicInteger(); + registry.register(countingService(invocations)); + var securedBus = new ServiceBus(registry, new AccessPolicy(Set.of("key"), Set.of("counter"))); + + var response = securedBus.send(new ServiceRequest("counter", "count", Map.of(), "wrong-key")); + + assertFalse(response.success()); + assertEquals("Access denied to counter", response.message()); + assertEquals(0, invocations.get()); + } + + @Test + void shouldDenyProtectedServiceBeforeRevealingThatItIsNotRegistered() { + var securedBus = new ServiceBus(registry, new AccessPolicy(Set.of("key"), Set.of("counter"))); + + var response = securedBus.send(new ServiceRequest("counter", "count", Map.of())); + + assertFalse(response.success()); + assertEquals("Access denied to counter", response.message()); + } + + @Test + void shouldPassValidCredentialThroughToProtectedService() { + var invocations = new AtomicInteger(); + registry.register(countingService(invocations)); + var securedBus = new ServiceBus(registry, new AccessPolicy(Set.of("key"), Set.of("counter"))); + + var response = securedBus.send(new ServiceRequest("counter", "count", Map.of(), "key")); + + assertTrue(response.success()); + assertEquals(1, response.body()); + assertEquals(1, invocations.get()); + } + + private static Service countingService(AtomicInteger invocations) { + return new Service() { + @Override + public String name() { + return "counter"; + } + + @Override + public ServiceResponse handle(ServiceRequest request) { + return ServiceResponse.ok(invocations.incrementAndGet()); + } + }; + } +} diff --git a/service-oriented-architecture/src/test/java/com/iluwatar/soa/ServiceRegistryTest.java b/service-oriented-architecture/src/test/java/com/iluwatar/soa/ServiceRegistryTest.java new file mode 100644 index 000000000000..ce1439c67d5d --- /dev/null +++ b/service-oriented-architecture/src/test/java/com/iluwatar/soa/ServiceRegistryTest.java @@ -0,0 +1,61 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.soa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; +import org.junit.jupiter.api.Test; + +class ServiceRegistryTest { + + private final ServiceRegistry registry = new ServiceRegistry(); + + @Test + void shouldLookupRegisteredService() { + var service = new CustomerService(); + registry.register(service); + + assertSame(service, registry.lookup(CustomerService.NAME).orElseThrow()); + assertEquals(Set.of(CustomerService.NAME), registry.serviceNames()); + } + + @Test + void shouldReturnEmptyForUnknownService() { + assertTrue(registry.lookup("missing").isEmpty()); + } + + @Test + void shouldRejectDuplicateRegistration() { + registry.register(new PaymentService()); + + var exception = + assertThrows(IllegalStateException.class, () -> registry.register(new PaymentService())); + assertEquals("Service already registered: payment", exception.getMessage()); + } +}