diff --git a/pom.xml b/pom.xml
index 4357996d..9a3e6414 100644
--- a/pom.xml
+++ b/pom.xml
@@ -117,6 +117,24 @@
exec-maven-plugin
3.1.0
+
+ docker-up
+
+ exec
+
+
+ docker
+
+ compose
+ -f
+ docker-compose.dev.yml
+ up
+ -d
+ --wait
+ db
+
+
+
docker-down
diff --git a/src/main/java/com/petshop/backend/DevStackApplication.java b/src/main/java/com/petshop/backend/DevStackApplication.java
new file mode 100644
index 00000000..a049b7ee
--- /dev/null
+++ b/src/main/java/com/petshop/backend/DevStackApplication.java
@@ -0,0 +1,154 @@
+package com.petshop.backend;
+
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.context.event.ContextClosedEvent;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class DevStackApplication {
+
+ private static final Duration WATCH_INTERVAL = Duration.ofSeconds(2);
+
+ public static void main(String[] args) {
+ DevStackController controller = new DevStackController();
+ ConfigurableApplicationContext context = null;
+ CountDownLatch shutdownLatch = new CountDownLatch(1);
+
+ try {
+ controller.startDatabase();
+ context = new SpringApplicationBuilder(BackendApplication.class).run(args);
+ ConfigurableApplicationContext appContext = context;
+ context.addApplicationListener(event -> {
+ if (event instanceof ContextClosedEvent) {
+ shutdownLatch.countDown();
+ }
+ });
+ controller.watchDatabase(appContext);
+ shutdownLatch.await();
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Dev stack interrupted", ex);
+ } finally {
+ controller.stopWatching();
+ if (context != null && context.isActive()) {
+ context.close();
+ }
+ controller.stopDatabase();
+ }
+ }
+
+ private static final class DevStackController {
+ private final Path projectDir = Paths.get("").toAbsolutePath();
+ private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
+ private ScheduledExecutorService scheduler;
+
+ private void startDatabase() {
+ runCommand(composeCommand("up", "-d", "--wait", "db"));
+ }
+
+ private void stopDatabase() {
+ if (!shuttingDown.compareAndSet(false, true)) {
+ return;
+ }
+ stopWatching();
+ runCommand(composeCommand("stop", "db"));
+ }
+
+ private void watchDatabase(ConfigurableApplicationContext context) {
+ scheduler = Executors.newSingleThreadScheduledExecutor();
+ scheduler.scheduleWithFixedDelay(() -> {
+ if (shuttingDown.get()) {
+ return;
+ }
+ if (!isDatabaseRunning()) {
+ shuttingDown.set(true);
+ if (context.isActive()) {
+ context.close();
+ }
+ }
+ }, WATCH_INTERVAL.toSeconds(), WATCH_INTERVAL.toSeconds(), TimeUnit.SECONDS);
+ }
+
+ private void stopWatching() {
+ if (scheduler != null) {
+ scheduler.shutdownNow();
+ scheduler = null;
+ }
+ }
+
+ private boolean isDatabaseRunning() {
+ CommandResult result = runCommand(composeCommand("ps", "--status", "running", "--services", "db"), false);
+ return result.exitCode == 0 && result.output.lines()
+ .map(String::trim)
+ .anyMatch("db"::equals);
+ }
+
+ private List composeCommand(String... args) {
+ List command = new ArrayList<>();
+ command.add(resolveDockerExecutable());
+ command.add("compose");
+ command.add("-f");
+ command.add("docker-compose.dev.yml");
+ for (String arg : args) {
+ command.add(arg);
+ }
+ return command;
+ }
+
+ private String resolveDockerExecutable() {
+ String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
+ return os.contains("win") ? "docker.exe" : "docker";
+ }
+
+ private void runCommand(List command) {
+ CommandResult result = runCommand(command, true);
+ if (result.exitCode != 0) {
+ throw new IllegalStateException(result.output.isBlank() ? "Command failed: " + String.join(" ", command) : result.output);
+ }
+ }
+
+ private CommandResult runCommand(List command, boolean printOutput) {
+ ProcessBuilder builder = new ProcessBuilder(command);
+ builder.directory(projectDir.toFile());
+ builder.redirectErrorStream(true);
+
+ try {
+ Process process = builder.start();
+ StringBuilder output = new StringBuilder();
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ output.append(line).append(System.lineSeparator());
+ if (printOutput) {
+ System.out.println(line);
+ }
+ }
+ }
+ int exitCode = process.waitFor();
+ return new CommandResult(exitCode, output.toString().trim());
+ } catch (IOException ex) {
+ throw new IllegalStateException("Unable to run docker command", ex);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Docker command interrupted", ex);
+ }
+ }
+ }
+
+ private record CommandResult(int exitCode, String output) {
+ }
+}