diff --git a/README.md b/README.md index 69bcc23..a067797 100644 --- a/README.md +++ b/README.md @@ -48,26 +48,40 @@ Then reference it in your project's `pom.xml`: ## Architecture -The library follows a layered architecture: +The library follows a layered architecture with constructor-based dependency injection. The public service interfaces describe application use cases, service implementations orchestrate those use cases, domain ports isolate request execution, and infrastructure abstractions model low-level request handling. ```text pl.vtt.wpi.core ├── application │ ├── config # AuthorizationHolder (thread-local auth state) +│ ├── context # ApplicationServices facade + lazy composition root │ ├── exception # Application-level exceptions │ └── service │ ├── ... # Service interfaces (LoginService, RuntimeDataService, etc.) -│ └── impl # Internal service implementations +│ └── impl # Service implementations wired through constructors ├── domain -│ ├── dto -│ ├── model -│ └── port # Input/Output port contracts + endpoint-specific port implementations +│ ├── dto # Request DTOs used by application services and ports +│ ├── model # Domain values and device state models +│ └── port # Input/Output contracts + endpoint-specific port implementations └── infrastructure ├── Request / Response ├── RequestFactory / RequestHandler / RequestSender └── factory # SynchronizedRequestFactory ``` +### Layer responsibilities + +| Layer | Responsibility | +|---|---| +| `application.service` | Stable use-case API exposed to library consumers. | +| `application.service.impl` | Use-case orchestration, validation, exception mapping, and constructor-injected dependencies. | +| `application.context` | Optional composition helper that lazily creates default service implementations from supplied ports. | +| `domain.model` / `domain.dto` | Shared domain values, device data records, credentials, users, and request DTOs. | +| `domain.port` | Generic `InputPort` / `OutputPort` contracts and endpoint-specific port implementations. | +| `infrastructure` | Request/response abstractions and request factory/sender/handler contracts used by ports. | + +Application services do not create their own ports. Instead, callers either instantiate service implementations directly with constructor injection or use `LazyApplicationServices` as a small composition root. `LazyApplicationServices` accepts `Supplier` instances for all required ports, resolves each supplier only when a dependent service is first requested, and then reuses the created service instance. + ## Key Concepts ### Authentication @@ -116,7 +130,7 @@ Request request = new Request<>( ); ``` -### Application Services +### Application Services and lazy composition The package `pl.vtt.wpi.core.application.service` currently exposes interfaces for: @@ -130,7 +144,40 @@ The package `pl.vtt.wpi.core.application.service` currently exposes interfaces f - `DebugService` - `RebootService` -Concrete implementations are provided in `pl.vtt.wpi.core.application.service.impl`. +Concrete implementations are provided in `pl.vtt.wpi.core.application.service.impl`. They use constructor injection, so tests and applications can provide fake or real `InputPort` / `OutputPort` implementations explicitly. + +For applications that want a single access point, `pl.vtt.wpi.core.application.context` provides: + +- `ApplicationServices` — a facade exposing getters for all service interfaces. +- `LazyApplicationServices` — a builder-based implementation that creates service implementations on first use. +- `Lazy` — a thread-safe memoizing supplier used internally by the composition root. + +Example composition: + +```java +ApplicationServices services = LazyApplicationServices.builder() + .authOutputPort(() -> authOutputPort) + .adminPasswordResetInputPort(() -> adminPasswordResetInputPort) + .usersOutputPort(() -> usersOutputPort) + .userCreateRequestInputPort(() -> userCreateRequestInputPort) + .changePasswordInputPort(() -> changePasswordInputPort) + .removeUserInputPort(() -> removeUserInputPort) + .runtimeDataOutputPort(() -> runtimeDataOutputPort) + .pixelProgramsOutputPort(() -> pixelProgramsOutputPort) + .runtimeDataInputPort(() -> runtimeDataInputPort) + .pixelProgramsInputPort(() -> pixelProgramsInputPort) + .wifiConfigInputPort(() -> wifiConfigInputPort) + .deviceInfoOutputPort(() -> deviceInfoOutputPort) + .logsOutputPort(() -> logsOutputPort) + .logsDeleteInputPort(() -> logsDeleteInputPort) + .currentStateOutputPort(() -> currentStateOutputPort) + .rebootInputPort(() -> rebootInputPort) + .build(); + +RuntimeDataService runtimeDataService = services.runtimeDataService(); +``` + +Only `runtimeDataService`, `runtimeDataOutputPort`, `pixelProgramsOutputPort`, and `runtimeDataInputPort` are resolved by the final line in this example; unrelated ports remain uninitialized until another service is requested. ### Domain Ports diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index d8c38e1..36c5118 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,4 +1,5 @@ module wpi.core { + exports pl.vtt.wpi.core.application.context; exports pl.vtt.wpi.core.application.exception; exports pl.vtt.wpi.core.application.service; exports pl.vtt.wpi.core.domain.dto; diff --git a/src/main/java/pl/vtt/wpi/core/application/context/ApplicationServices.java b/src/main/java/pl/vtt/wpi/core/application/context/ApplicationServices.java new file mode 100644 index 0000000..7a1162f --- /dev/null +++ b/src/main/java/pl/vtt/wpi/core/application/context/ApplicationServices.java @@ -0,0 +1,31 @@ +package pl.vtt.wpi.core.application.context; + +import pl.vtt.wpi.core.application.service.AdminPasswordService; +import pl.vtt.wpi.core.application.service.DebugService; +import pl.vtt.wpi.core.application.service.DeviceInfoService; +import pl.vtt.wpi.core.application.service.LoginService; +import pl.vtt.wpi.core.application.service.NetworkConfigurationService; +import pl.vtt.wpi.core.application.service.PixelProgramService; +import pl.vtt.wpi.core.application.service.RebootService; +import pl.vtt.wpi.core.application.service.RuntimeDataService; +import pl.vtt.wpi.core.application.service.UserManagementService; + +public interface ApplicationServices { + LoginService loginService(); + + AdminPasswordService adminPasswordService(); + + UserManagementService userManagementService(); + + RuntimeDataService runtimeDataService(); + + PixelProgramService pixelProgramService(); + + NetworkConfigurationService networkConfigurationService(); + + DeviceInfoService deviceInfoService(); + + DebugService debugService(); + + RebootService rebootService(); +} diff --git a/src/main/java/pl/vtt/wpi/core/application/context/Lazy.java b/src/main/java/pl/vtt/wpi/core/application/context/Lazy.java new file mode 100644 index 0000000..66375a5 --- /dev/null +++ b/src/main/java/pl/vtt/wpi/core/application/context/Lazy.java @@ -0,0 +1,36 @@ +package pl.vtt.wpi.core.application.context; + +import java.util.Objects; +import java.util.function.Supplier; + +public final class Lazy implements Supplier { + private volatile Supplier initializer; + private volatile T value; + + private Lazy(Supplier initializer) { + this.initializer = Objects.requireNonNull(initializer, "initializer cannot be null"); + } + + public static Lazy of(Supplier initializer) { + return new Lazy<>(initializer); + } + + @Override + public T get() { + Supplier currentInitializer = initializer; + if (currentInitializer == null) { + return value; + } + synchronized (this) { + currentInitializer = initializer; + if (currentInitializer == null) { + return value; + } + T current = Objects.requireNonNull(currentInitializer.get(), + "initializer cannot return null"); + value = current; + initializer = null; + return current; + } + } +} diff --git a/src/main/java/pl/vtt/wpi/core/application/context/LazyApplicationServices.java b/src/main/java/pl/vtt/wpi/core/application/context/LazyApplicationServices.java new file mode 100644 index 0000000..a1fa61f --- /dev/null +++ b/src/main/java/pl/vtt/wpi/core/application/context/LazyApplicationServices.java @@ -0,0 +1,259 @@ +package pl.vtt.wpi.core.application.context; + +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; +import pl.vtt.wpi.core.application.service.AdminPasswordService; +import pl.vtt.wpi.core.application.service.DebugService; +import pl.vtt.wpi.core.application.service.DeviceInfoService; +import pl.vtt.wpi.core.application.service.LoginService; +import pl.vtt.wpi.core.application.service.NetworkConfigurationService; +import pl.vtt.wpi.core.application.service.PixelProgramService; +import pl.vtt.wpi.core.application.service.RebootService; +import pl.vtt.wpi.core.application.service.RuntimeDataService; +import pl.vtt.wpi.core.application.service.UserManagementService; +import pl.vtt.wpi.core.application.service.impl.AdminPasswordServiceImpl; +import pl.vtt.wpi.core.application.service.impl.DebugServiceImpl; +import pl.vtt.wpi.core.application.service.impl.DeviceInfoServiceImpl; +import pl.vtt.wpi.core.application.service.impl.LoginServiceImpl; +import pl.vtt.wpi.core.application.service.impl.NetworkConfigurationServiceImpl; +import pl.vtt.wpi.core.application.service.impl.PixelProgramServiceImpl; +import pl.vtt.wpi.core.application.service.impl.RebootServiceImpl; +import pl.vtt.wpi.core.application.service.impl.RuntimeDataServiceImpl; +import pl.vtt.wpi.core.application.service.impl.UserManagementServiceImpl; +import pl.vtt.wpi.core.domain.dto.AdminPasswordResetRequest; +import pl.vtt.wpi.core.domain.dto.PasswordDto; +import pl.vtt.wpi.core.domain.dto.UserCreateRequest; +import pl.vtt.wpi.core.domain.model.Credentials; +import pl.vtt.wpi.core.domain.model.User; +import pl.vtt.wpi.core.domain.model.device.CurrentState; +import pl.vtt.wpi.core.domain.model.device.DeviceInfo; +import pl.vtt.wpi.core.domain.model.device.PixelProgram; +import pl.vtt.wpi.core.domain.model.device.RuntimeData; +import pl.vtt.wpi.core.domain.model.device.WifiConfig; +import pl.vtt.wpi.core.domain.port.InputPort; +import pl.vtt.wpi.core.domain.port.OutputPort; + +public final class LazyApplicationServices implements ApplicationServices { + private final Lazy> authOutputPort; + private final Lazy> adminPasswordResetInputPort; + private final Lazy>> usersOutputPort; + private final Lazy> userCreateRequestInputPort; + private final Lazy> changePasswordInputPort; + private final Lazy> removeUserInputPort; + private final Lazy> runtimeDataOutputPort; + private final Lazy>> pixelProgramsOutputPort; + private final Lazy> runtimeDataInputPort; + private final Lazy>> pixelProgramsInputPort; + private final Lazy> wifiConfigInputPort; + private final Lazy> deviceInfoOutputPort; + private final Lazy>> logsOutputPort; + private final Lazy> logsDeleteInputPort; + private final Lazy> currentStateOutputPort; + private final Lazy> rebootInputPort; + + private final Lazy loginService; + private final Lazy adminPasswordService; + private final Lazy userManagementService; + private final Lazy runtimeDataService; + private final Lazy pixelProgramService; + private final Lazy networkConfigurationService; + private final Lazy deviceInfoService; + private final Lazy debugService; + private final Lazy rebootService; + + private LazyApplicationServices(Builder builder) { + this.authOutputPort = lazy(builder.authOutputPort, "authOutputPort"); + this.adminPasswordResetInputPort = lazy(builder.adminPasswordResetInputPort, + "adminPasswordResetInputPort"); + this.usersOutputPort = lazy(builder.usersOutputPort, "usersOutputPort"); + this.userCreateRequestInputPort = lazy(builder.userCreateRequestInputPort, + "userCreateRequestInputPort"); + this.changePasswordInputPort = lazy(builder.changePasswordInputPort, "changePasswordInputPort"); + this.removeUserInputPort = lazy(builder.removeUserInputPort, "removeUserInputPort"); + this.runtimeDataOutputPort = lazy(builder.runtimeDataOutputPort, "runtimeDataOutputPort"); + this.pixelProgramsOutputPort = lazy(builder.pixelProgramsOutputPort, "pixelProgramsOutputPort"); + this.runtimeDataInputPort = lazy(builder.runtimeDataInputPort, "runtimeDataInputPort"); + this.pixelProgramsInputPort = lazy(builder.pixelProgramsInputPort, "pixelProgramsInputPort"); + this.wifiConfigInputPort = lazy(builder.wifiConfigInputPort, "wifiConfigInputPort"); + this.deviceInfoOutputPort = lazy(builder.deviceInfoOutputPort, "deviceInfoOutputPort"); + this.logsOutputPort = lazy(builder.logsOutputPort, "logsOutputPort"); + this.logsDeleteInputPort = lazy(builder.logsDeleteInputPort, "logsDeleteInputPort"); + this.currentStateOutputPort = lazy(builder.currentStateOutputPort, "currentStateOutputPort"); + this.rebootInputPort = lazy(builder.rebootInputPort, "rebootInputPort"); + + this.loginService = Lazy.of(() -> new LoginServiceImpl(authOutputPort.get())); + this.adminPasswordService = Lazy.of(() -> new AdminPasswordServiceImpl(adminPasswordResetInputPort.get())); + this.userManagementService = Lazy.of(() -> new UserManagementServiceImpl(usersOutputPort.get(), + userCreateRequestInputPort.get(), changePasswordInputPort.get(), removeUserInputPort.get())); + this.runtimeDataService = Lazy.of(() -> new RuntimeDataServiceImpl(runtimeDataOutputPort.get(), + pixelProgramsOutputPort.get(), runtimeDataInputPort.get())); + this.pixelProgramService = Lazy.of(() -> new PixelProgramServiceImpl(pixelProgramsOutputPort.get(), + pixelProgramsInputPort.get())); + this.networkConfigurationService = Lazy.of(() -> new NetworkConfigurationServiceImpl(wifiConfigInputPort.get())); + this.deviceInfoService = Lazy.of(() -> new DeviceInfoServiceImpl(deviceInfoOutputPort.get())); + this.debugService = Lazy.of(() -> new DebugServiceImpl(logsOutputPort.get(), logsDeleteInputPort.get(), + currentStateOutputPort.get())); + this.rebootService = Lazy.of(() -> new RebootServiceImpl(rebootInputPort.get())); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public LoginService loginService() { + return loginService.get(); + } + + @Override + public AdminPasswordService adminPasswordService() { + return adminPasswordService.get(); + } + + @Override + public UserManagementService userManagementService() { + return userManagementService.get(); + } + + @Override + public RuntimeDataService runtimeDataService() { + return runtimeDataService.get(); + } + + @Override + public PixelProgramService pixelProgramService() { + return pixelProgramService.get(); + } + + @Override + public NetworkConfigurationService networkConfigurationService() { + return networkConfigurationService.get(); + } + + @Override + public DeviceInfoService deviceInfoService() { + return deviceInfoService.get(); + } + + @Override + public DebugService debugService() { + return debugService.get(); + } + + @Override + public RebootService rebootService() { + return rebootService.get(); + } + + private static Lazy lazy(Supplier supplier, String name) { + return Lazy.of(Objects.requireNonNull(supplier, name + " supplier cannot be null")); + } + + public static final class Builder { + private Supplier> authOutputPort; + private Supplier> adminPasswordResetInputPort; + private Supplier>> usersOutputPort; + private Supplier> userCreateRequestInputPort; + private Supplier> changePasswordInputPort; + private Supplier> removeUserInputPort; + private Supplier> runtimeDataOutputPort; + private Supplier>> pixelProgramsOutputPort; + private Supplier> runtimeDataInputPort; + private Supplier>> pixelProgramsInputPort; + private Supplier> wifiConfigInputPort; + private Supplier> deviceInfoOutputPort; + private Supplier>> logsOutputPort; + private Supplier> logsDeleteInputPort; + private Supplier> currentStateOutputPort; + private Supplier> rebootInputPort; + + public Builder authOutputPort(Supplier> authOutputPort) { + this.authOutputPort = authOutputPort; + return this; + } + + public Builder adminPasswordResetInputPort( + Supplier> adminPasswordResetInputPort) { + this.adminPasswordResetInputPort = adminPasswordResetInputPort; + return this; + } + + public Builder usersOutputPort(Supplier>> usersOutputPort) { + this.usersOutputPort = usersOutputPort; + return this; + } + + public Builder userCreateRequestInputPort( + Supplier> userCreateRequestInputPort) { + this.userCreateRequestInputPort = userCreateRequestInputPort; + return this; + } + + public Builder changePasswordInputPort(Supplier> changePasswordInputPort) { + this.changePasswordInputPort = changePasswordInputPort; + return this; + } + + public Builder removeUserInputPort(Supplier> removeUserInputPort) { + this.removeUserInputPort = removeUserInputPort; + return this; + } + + public Builder runtimeDataOutputPort(Supplier> runtimeDataOutputPort) { + this.runtimeDataOutputPort = runtimeDataOutputPort; + return this; + } + + public Builder pixelProgramsOutputPort( + Supplier>> pixelProgramsOutputPort) { + this.pixelProgramsOutputPort = pixelProgramsOutputPort; + return this; + } + + public Builder runtimeDataInputPort(Supplier> runtimeDataInputPort) { + this.runtimeDataInputPort = runtimeDataInputPort; + return this; + } + + public Builder pixelProgramsInputPort( + Supplier>> pixelProgramsInputPort) { + this.pixelProgramsInputPort = pixelProgramsInputPort; + return this; + } + + public Builder wifiConfigInputPort(Supplier> wifiConfigInputPort) { + this.wifiConfigInputPort = wifiConfigInputPort; + return this; + } + + public Builder deviceInfoOutputPort(Supplier> deviceInfoOutputPort) { + this.deviceInfoOutputPort = deviceInfoOutputPort; + return this; + } + + public Builder logsOutputPort(Supplier>> logsOutputPort) { + this.logsOutputPort = logsOutputPort; + return this; + } + + public Builder logsDeleteInputPort(Supplier> logsDeleteInputPort) { + this.logsDeleteInputPort = logsDeleteInputPort; + return this; + } + + public Builder currentStateOutputPort(Supplier> currentStateOutputPort) { + this.currentStateOutputPort = currentStateOutputPort; + return this; + } + + public Builder rebootInputPort(Supplier> rebootInputPort) { + this.rebootInputPort = rebootInputPort; + return this; + } + + public ApplicationServices build() { + return new LazyApplicationServices(this); + } + } +} diff --git a/src/test/java/pl/vtt/wpi/core/application/context/LazyApplicationServicesTest.java b/src/test/java/pl/vtt/wpi/core/application/context/LazyApplicationServicesTest.java new file mode 100644 index 0000000..c0813c6 --- /dev/null +++ b/src/test/java/pl/vtt/wpi/core/application/context/LazyApplicationServicesTest.java @@ -0,0 +1,95 @@ +package pl.vtt.wpi.core.application.context; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import pl.vtt.wpi.core.domain.dto.AdminPasswordResetRequest; +import pl.vtt.wpi.core.domain.dto.PasswordDto; +import pl.vtt.wpi.core.domain.dto.UserCreateRequest; +import pl.vtt.wpi.core.domain.model.Credentials; +import pl.vtt.wpi.core.domain.model.User; +import pl.vtt.wpi.core.domain.model.device.CurrentState; +import pl.vtt.wpi.core.domain.model.device.DeviceInfo; +import pl.vtt.wpi.core.domain.model.device.PixelProgram; +import pl.vtt.wpi.core.domain.model.device.RuntimeData; +import pl.vtt.wpi.core.domain.model.device.WifiConfig; +import pl.vtt.wpi.core.domain.port.InputPort; +import pl.vtt.wpi.core.domain.port.OutputPort; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LazyApplicationServicesTest { + + @Test + @DisplayName("Creates services and dependencies lazily") + void creates_services_and_dependencies_lazily() { + AtomicInteger authPortCreations = new AtomicInteger(); + ApplicationServices services = baseBuilder() + .authOutputPort(() -> { + authPortCreations.incrementAndGet(); + return outputPort(new Credentials("admin", "token")); + }) + .build(); + + assertEquals(0, authPortCreations.get()); + + assertSame(services.loginService(), services.loginService()); + assertEquals(1, authPortCreations.get()); + } + + @Test + @DisplayName("Does not resolve unrelated dependencies") + void does_not_resolve_unrelated_dependencies() { + AtomicInteger runtimeDataPortCreations = new AtomicInteger(); + ApplicationServices services = baseBuilder() + .runtimeDataOutputPort(() -> { + runtimeDataPortCreations.incrementAndGet(); + return outputPort(null); + }) + .build(); + + assertNotNull(services.loginService()); + + assertEquals(0, runtimeDataPortCreations.get()); + } + + @Test + @DisplayName("Fails fast when a required supplier is missing") + void fails_fast_when_required_supplier_is_missing() { + LazyApplicationServices.Builder builder = baseBuilder().authOutputPort(null); + + assertThrows(NullPointerException.class, builder::build); + } + + private static LazyApplicationServices.Builder baseBuilder() { + return LazyApplicationServices.builder() + .authOutputPort(() -> outputPort(new Credentials("admin", "token"))) + .adminPasswordResetInputPort(() -> inputPort()) + .usersOutputPort(() -> outputPort(List.of())) + .userCreateRequestInputPort(() -> inputPort()) + .changePasswordInputPort(() -> inputPort()) + .removeUserInputPort(() -> inputPort()) + .runtimeDataOutputPort(() -> outputPort(null)) + .pixelProgramsOutputPort(() -> outputPort(List.of())) + .runtimeDataInputPort(() -> inputPort()) + .pixelProgramsInputPort(() -> inputPort()) + .wifiConfigInputPort(() -> inputPort()) + .deviceInfoOutputPort(() -> outputPort(null)) + .logsOutputPort(() -> outputPort(List.of())) + .logsDeleteInputPort(() -> inputPort()) + .currentStateOutputPort(() -> outputPort(null)) + .rebootInputPort(() -> inputPort()); + } + + private static OutputPort outputPort(T value) { + return () -> value; + } + + private static InputPort inputPort() { + return ignored -> { }; + } +}