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..a9c2951a4b76 --- /dev/null +++ b/service-oriented-architecture/README.md @@ -0,0 +1,319 @@ +--- +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 every service is stateless between calls, each one can be scaled independently. In the example the in-memory maps stand in for each service's own datastore, and the bus stands in for the network protocol (SOAP, REST or messaging) that would carry the messages in a deployed system. + +## 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 payload is plain data, which is what makes the contract interoperable: the same request could travel as SOAP, JSON or any other wire format. + +```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 resolves the target through the registry, applies cross-cutting concerns in a single place (access control, tracing, timing) and turns provider failures into error responses so that consumers never see provider exceptions. + +```java +@Slf4j +public class ServiceBus { + private final ServiceRegistry registry; + private final AccessPolicy policy; + + public ServiceBus(ServiceRegistry registry, AccessPolicy policy) { ... } + + public ServiceResponse send(ServiceRequest request) { + var service = registry.lookup(request.service()); + if (service.isEmpty()) { + return ServiceResponse.error("No such service: " + request.service()); + } + 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()); + } + 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 is stateless between calls. 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` and `reserve`, and `PaymentService` answers `charge` in the same style. + +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. + +```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() || !Boolean.TRUE.equals(stock.body())) { + return ServiceResponse.error("Order rejected: insufficient stock for " + sku); + } + var payment = bus.send(new ServiceRequest(PAYMENT_SERVICE, "charge", + Map.of("customerId", customerId, "amount", amount), credential)); + if (!payment.success()) { + return ServiceResponse.error("Order rejected: " + payment.message()); + } + bus.send(new ServiceRequest(INVENTORY_SERVICE, "reserve", + Map.of("sku", sku, "quantity", quantity), credential)); + 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. + +```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: + +``` +Registered service 'customer' (CustomerService) +Registered service 'inventory' (InventoryService) +Registered service 'payment' (PaymentService) +Registered service 'order' (OrderService) +-> order.placeOrder payload={amount=899.0, quantity=2, sku=LAPTOP, customerId=C-1} +-> customer.getCustomer payload={customerId=C-1} +<- customer.getCustomer success=true in 0 ms +-> inventory.checkStock payload={quantity=2, sku=LAPTOP} +<- inventory.checkStock success=true in 0 ms +-> payment.charge payload={amount=899.0, customerId=C-1} +<- payment.charge success=true in 0 ms +-> inventory.reserve payload={quantity=2, sku=LAPTOP} +<- inventory.reserve success=true in 0 ms +<- order.placeOrder success=true in 1 ms +Order outcome: ServiceResponse[success=true, body=OrderConfirmation[orderId=ORD-1, customerName=Alice Smith, sku=LAPTOP, quantity=2, paymentReference=PAY-1], message=] +... +Order outcome: ServiceResponse[success=false, body=null, message=Order rejected: insufficient stock for PHONE] +No service registered under 'shipping' +Bus outcome: ServiceResponse[success=false, body=null, message=No such service: shipping] +-> order.placeOrder payload={amount=899.0, quantity=1, sku=LAPTOP, customerId=C-1} +... +Access denied to payment.charge for anonymous caller +<- order.placeOrder success=false in 0 ms +Order outcome: ServiceResponse[success=false, body=null, message=Order rejected: Access denied to payment] +``` + +## Class diagram + +See [service-oriented-architecture.urm.puml](./etc/service-oriented-architecture.urm.puml) for the PlantUML class diagram. + +## 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.puml b/service-oriented-architecture/etc/service-oriented-architecture.urm.puml new file mode 100644 index 000000000000..4ea805954d65 --- /dev/null +++ b/service-oriented-architecture/etc/service-oriented-architecture.urm.puml @@ -0,0 +1,114 @@ +@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, email : String) + + id() : String + + name() : String + + email() : String + } + class CustomerService { + + NAME : String {static} + - customers : Map + + CustomerService() + + CustomerService(customers : Map) + + name() : String + + handle(request : ServiceRequest) : ServiceResponse + } + class InventoryService { + + NAME : String {static} + - stock : Map + + InventoryService() + + InventoryService(initialStock : Map) + + name() : String + + handle(request : ServiceRequest) : ServiceResponse + } + class PaymentService { + + NAME : String {static} + - creditLimit : double + - sequence : AtomicInteger + + PaymentService() + + PaymentService(creditLimit : double) + + name() : String + + handle(request : ServiceRequest) : ServiceResponse + } + class OrderService { + + NAME : String {static} + - bus : ServiceBus + - sequence : AtomicInteger + + OrderService(bus : ServiceBus) + + name() : String + + handle(request : ServiceRequest) : ServiceResponse + } + class OrderConfirmation { + + OrderConfirmation(orderId : String, customerName : String, sku : String, quantity : int, paymentReference : String) + + orderId() : 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..716c0a5d076b --- /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}: stateless + * enterprise services that each own one business capability + *
  • {@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..de9c12e31c31 --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/Customer.java @@ -0,0 +1,34 @@ +/* + * 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 + * @param email the contact address + */ +public record Customer(String id, String name, String email) {} 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..24cf9dede394 --- /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", "alice@example.com"), + "C-2", new Customer("C-2", "Bob Jones", "bob@example.com"))); + } + + /** 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..3114a32ea776 --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/InventoryService.java @@ -0,0 +1,102 @@ +/* + * 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 + *
+ */ +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)); + default -> ServiceResponse.error("Unknown operation: " + request.operation()); + }; + } + + private ServiceResponse checkStock(String sku, int quantity) { + return ServiceResponse.ok(stock.getOrDefault(sku, 0) >= quantity); + } + + private ServiceResponse reserve(String sku, int 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); + } +} 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..aba22860310f --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/OrderService.java @@ -0,0 +1,141 @@ +/* + * 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. + * + *

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 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 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()); + } + + var stock = + bus.send( + new ServiceRequest( + INVENTORY_SERVICE, + "checkStock", + Map.of("sku", sku, "quantity", quantity), + credential)); + if (!stock.success() || !Boolean.TRUE.equals(stock.body())) { + return ServiceResponse.error("Order rejected: insufficient stock for " + sku); + } + + var payment = + bus.send( + new ServiceRequest( + PAYMENT_SERVICE, + "charge", + Map.of("customerId", customerId, "amount", amount), + credential)); + if (!payment.success()) { + return ServiceResponse.error("Order rejected: " + payment.message()); + } + + 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 confirmation = + new OrderConfirmation( + "ORD-" + sequence.incrementAndGet(), + ((Customer) customer.body()).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..a9e11076c2bd --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/Service.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 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 must be stateless between calls: every request + * carries everything the service needs to process it. + * + * @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..fc6615d0f7d7 --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceBus.java @@ -0,0 +1,93 @@ +/* + * 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 discovers the target service in the {@link + * ServiceRegistry}, applies cross-cutting concerns in one place (access control through the {@link + * AccessPolicy}, tracing and timing) and shields consumers from provider failures by translating + * exceptions into error responses. 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) { + 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()); + } + 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()); + } + 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..169dc44d80b2 --- /dev/null +++ b/service-oriented-architecture/src/main/java/com/iluwatar/soa/ServiceRequest.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.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 payload is plain data rather than + * typed Java objects, the same contract could be transported as SOAP, JSON or any other + * interoperable format. + * + * @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..ab12fed456f7 --- /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", "bob@example.com"), 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..f852cf5261a4 --- /dev/null +++ b/service-oriented-architecture/src/test/java/com/iluwatar/soa/InventoryServiceTest.java @@ -0,0 +1,90 @@ +/* + * 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 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))); + } +} 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..89e9b81427c5 --- /dev/null +++ b/service-oriented-architecture/src/test/java/com/iluwatar/soa/OrderServiceTest.java @@ -0,0 +1,187 @@ +/* + * 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 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("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 shouldRejectOrderWhenReservationFails() { + var stubBus = + busWithInventoryStub(ServiceResponse.ok(true), ServiceResponse.error("reservation failed")); + + var response = placeOrder(stubBus, "C-1", "LAPTOP", 1, 899.0, CREDENTIAL); + + assertFalse(response.success()); + assertEquals("Order rejected: reservation failed", response.message()); + } + + @Test + void shouldRejectOrderWhenStockCheckFails() { + var stubBus = + busWithInventoryStub(ServiceResponse.error("inventory unavailable"), ServiceResponse.ok(0)); + + var response = placeOrder(stubBus, "C-1", "LAPTOP", 1, 899.0, CREDENTIAL); + + assertFalse(response.success()); + assertEquals("Order rejected: insufficient stock for LAPTOP", response.message()); + } + + @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) { + var registry = new ServiceRegistry(); + var stubBus = + 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(stubBus)); + registry.register( + new Service() { + @Override + public String name() { + return "inventory"; + } + + @Override + public ServiceResponse handle(ServiceRequest request) { + return "checkStock".equals(request.operation()) ? checkStockResponse : reserveResponse; + } + }); + return stubBus; + } + + 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..7261641f1b1c --- /dev/null +++ b/service-oriented-architecture/src/test/java/com/iluwatar/soa/ServiceBusTest.java @@ -0,0 +1,134 @@ +/* + * 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 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()); + } +}