Polyglot Ecosystem Engineering: DI, Language Bridges, and CI/CD for Java Projects
Part 6 of 7 in Mastering Modern Java Jvm
Intro
Java rarely lives in isolation. In a mature polyglot ecosystem — Spring Boot on the JVM, Python for data science, Go for sidecar utilities, Node.js for edge logic — you need consistent patterns for wiring components, delegating work across runtimes, and automating builds.
This post walks through three concrete patterns: constructor-based dependency injection (the mechanics frameworks like Spring automate), a Java-to-Python process bridge for offloading computation, and the build/config files that stitch everything into CI/CD. The first two sections contain runnable demos; the third shows the project scaffolding you’d use in production.
Dependency Injection
Dependency injection is about removing the “where do my dependencies come from” question at runtime. Instead of a class creating its own collaborators (new ConfigRepository()), it declares what it needs and receives them from outside.
The smallest unit is an interface — just the contract, no implementation:
interface ConfigRepository {
String get(String key);
}
You then write multiple implementations, each sourcing data differently. In production you’d have a FileConfigRepository reading from .properties, an EnvConfigRepository looking up APP_* environment variables, and a DefaultConfigRepository that returns hardcoded values:
class FileConfigRepository implements ConfigRepository {
// Reads from a properties map (simulating file I/O)
}
class EnvConfigRepository implements ConfigRepository {
public String get(String key) {
return System.getenv("APP_" + key.replace('.', '_').toUpperCase());
}
}
The service layer depends only on the interface:
class ApplicationService {
private final ConfigRepository config;
// Constructor injection — no new inside the class
public ApplicationService(ConfigRepository config) {
this.config = config;
}
}
This pattern has a simple but powerful consequence: you can swap the entire configuration source at wiring time without touching the service code. In production Spring does this via @Bean methods and component scan; here we wire it manually to show what happens under the hood.
The output above demonstrates two things. First, ApplicationService reads all its configuration through the ConfigRepository abstraction — when wired with FileConfigRepository, it sees five properties (app name, version, pipeline type, batch size). When wired with EnvConfigRepository, those values come from environment variables (which aren’t set in this session, so the service falls back to null and uses a default).
Second, the wiring point — just one line in main() — determines which configuration source the entire application uses. That’s inversion of control: the flow of dependencies is inverted from “I create my deps” to “my deps are given to me.” This is what makes unit testing straightforward (inject a mock ConfigRepository) and deployment flexible (change one variable, get a different runtime behavior).
Language Integration
Java’s standard library includes ProcessBuilder — a first-class way to spawn and communicate with child processes. In a polyglot service this is how you call Python for data science, Go for compiled utilities, or Node.js for templating without embedding those runtimes in the JVM.
The pattern works like this: Java prepares a structured input (JSON over stdin), spawns the child process, and reads the response from stdout. Error handling happens through exit codes and stderr — no shared memory, no network overhead.
The video above shows three successful batch calls and one error path. For each batch, Java builds a JSON payload ({"data":[10, 25, 30, 45, 60]}), writes it to Python’s stdin, and reads the statistics back as JSON ({"count": 5, "mean": 34.0, "min": 10, "max": 60}). The Python process is completely isolated — it exits after computing each batch, leaving the Java process with its heap intact.
When invalid data arrives (an array where a list was expected), Python throws a TypeError on stderr and exits with code 1. Java catches the non-zero exit code and converts it to a RuntimeException. This is why the error path in the output reads "Python exited with code 1" — not a crash, but a graceful failure that Java can handle.
In practice you’d do this for heavy numerical work (pandas DataFrames, numpy arrays), machine learning inference (scikit-learn models), or any task where Python’s ecosystem does the job faster than equivalent Java. The communication contract is JSON — no type mapping, no serialization library needed.
Build and Deployment Configuration
A polyglot project needs build files that coordinate Java compilation with Docker image assembly and CI/CD pipelines.
Maven multi-module project (parent pom.xml):
<?xml version="1.0"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>polyglot-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>service-core</module>
<module>shared-config</module>
</modules>
<properties>
<java.version>21</java.version>
<spring-boot.version>3.3.5</spring-boot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
The parent POM uses dependencyManagement to version-consolidate Spring Boot (and transitively all its dependencies) so child modules don’t specify versions. The service-core module then inherits Java 21 compilation and the spring-boot-starter-web dependency without re-specifying them.
Multi-stage Dockerfile:
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
COPY pom.xml service-core/pom.xml ./service-core/
RUN mvn -f pom.xml dependency:go-offline -B
COPY service-core/src ./service-core/src
RUN mvn -f pom.xml clean package -DskipTests -B
FROM eclipse-temurin:21-jre-alpine AS runtime
COPY --from=build /build/service-core/target/*.jar app.jar
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
The two-stage build keeps the final image small (~200 MB instead of ~1 GB) by excluding Maven and source files. dependency:go-offline downloads all artifacts in the first RUN so the second layer (which changes frequently) doesn’t invalidate the dependency cache.
GitHub Actions workflow:
name: Build, Test, and Deploy
on:
push: { branches: [main] }
pull_request: { branches: [main] }
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- run: mvn verify -B
deploy:
needs: build-and-test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t polyglot-service:${{ github.sha }} .
The CI job compiles and tests on every PR. The deploy job (triggered only by pushes to main) builds a Docker image tagged with the commit hash — this is where you’d add the registry push step.
Takeaway
Dependency injection makes your service testable and deployment-flexible by inverting who controls object creation. ProcessBuilder bridges Java to other language ecosystems without network overhead or framework dependencies. Maven, Docker multi-stage builds, and GitHub Actions coordinate these pieces into a repeatable build pipeline — each layer solving a different problem (dependency versioning, image size, automation). Together they form the scaffolding that lets a Java service coexist peacefully with Python, Go, and Node in a polyglot architecture.