Machine learning has changed enterprise software in ways many teams did not expect. In many Spring Boot systems, predictions now shape prices, quality checks, anomaly alerts, fraud decisions, recommendations, and planning. But teams still shape prediction code like a small helper function. A controller calls a service. The service loads a model or sends a request to an endpoint. A score comes back. For a proof of concept, such a setup often works. In production, such a setup breaks down fast.
Table of Contents
Once your team needs reproducibility, rollback, audit trails, controlled rollout, or more than one deployment option, the model stops being a minor detail. The model becomes part of the architecture. Your application needs more than a way to ask for a prediction. Your application also needs clear rules for model selection, version tracking, later review, and changes in infrastructure without damage to business logic.
This is where hexagonal architecture helps. The idea is simple. Business use cases stay in the core. External technology stays outside the core and connects through ports and adapters. In classic examples, those external parts include databases, user interfaces, and messaging. In machine learning systems, those parts also include model registries, inference runtimes, feature providers, remote model servers, and audit stores. This view treats model serving as an outbound dependency, not as a hidden part of the core. That distinction matters. Alistair Cockburn’s original idea still fits well here because the goal stays the same: protect the application from outside technology and keep boundaries clear and testable.
This article looks at how hexagonal architecture helps you structure machine learning features in a Spring Boot application. The goal is not another Java machine learning tutorial. The goal is to show how ports and adapters make prediction use cases easier to change, test, and govern.
The Model Changed – The Architecture Wasn’t Ready
Let us consider as an example a quality control service on a production line. A computer vision model checks weld seams in real time. It spots porosity, undercut, and overlap in camera images right after welding. The service does its job. Then, six months later, the data science team ships a better model. Accuracy is higher. False alarms drop. The model file is smaller. From a software architecture view, this kind of change should stay simple. You replace the artifact, deploy again, and move on. Instead, the change takes three weeks and ends with two hotfixes.
The real problem is the tight mix of concerns. WeldInspectionService pulls in the ONNX runtime directly. It builds the input tensor, runs inference, reads the raw float output, and turns those values into domain severity levels, all inside one class. That design ties business rules to model-serving details. Once the new model changes its output schema, the business logic fails. Once the endpoint changes, the integration tests fail. The model changed, yet the impact spread across the whole service.
This is the price of tight coupling between machine learning infrastructure and business logic. In the writer’s view, this is where production systems lose clarity and speed. Hexagonal Architecture offers a cleaner path. It separates business decisions from technical details and reduces the impact of change, which aligns with the boundary-focused view described by Cockburn (2005).
Hexagonal Architecture in 60 Seconds
Hexagonal Architecture, often called Ports and Adapters, structures a service around a domain core. That core stays free from external infrastructure. It does not depend on database code, messaging code, HTTP clients, or model runtime details. Instead, the domain states its needs through interfaces called ports. Adapters provide the concrete implementations of those ports. You are free to replace an adapter without changing the domain logic. From an architecture view, this separation is one of the main strengths of the style.
In this setup, two kinds of ports matter. Driving ports are the entry points into the domain. A REST controller or a Kafka consumer uses a driving port to trigger a use case. Driven ports work in the other direction. They describe the external services the domain needs to call. These often include a database, a message bus, or, in machine learning systems, the model used for inference.
For a machine learning service, the inference step belongs behind a driven port. The domain should not know where the prediction comes from. It should not care whether inference runs in an ONNX runtime inside the JVM, in a Python service over HTTP, in a cloud endpoint, or in a test double with fixed results. The domain calls the port. The adapter connects that call to the real implementation.
This matters in production. Once you keep inference behind a port, you reduce the impact of change. You swap serving technology, adjust deployment style, or test business rules in isolation without rewriting the core logic. That makes the system easier to understand, easier to test, and easier to change.

Hexagonal Architecture is not a naming convention, and it is certainly not just a folder structure. At its core, it is a dependency rule: the domain must remain independent of infrastructure. Infrastructure may depend on the domain, but the domain must never depend on infrastructure.
In practice, that means every import in the domain deserves scrutiny. Before adding one, it is worth asking a simple question: does this type belong to the domain itself, or does it come from the outside? If the answer is Spring, ONNX, JDBC, Jackson, or any other infrastructure technology, the boundary is already starting to erode.
Domain Model
The right place to begin is the domain model, because it defines the vocabulary of the system. These core concepts should be implemented as plain Java records and enums – nothing more. No annotations, no framework glue, no accidental coupling to infrastructure. Ideally, the domain should compile against the JDK alone.
domain/model/WeldImageSample.java
package com.example.weldinspection.domain.model;
import java.time.Instant;
public record WeldImageSample(
String jointId,
byte[] imageData, // raw image bytes from camera
String cameraId,
Instant capturedAt
) {}
domain/model/DefectType.java
package com.example.weldinspection.domain.model;
public enum DefectType {
POROSITY, UNDERCUT, OVERLAP, NONE
}
domain/model/DefectPrediction.java
package com.example.weldinspection.domain.model;
public record DefectPrediction(
DefectType type,
double confidence,
String modelVersion
) {}
domain/model/Severity.java
package com.example.weldinspection.domain.model;
public enum Severity { OK, LOW, MEDIUM, HIGH, INCONCLUSIVE }
domain/model/InspectionResult.java
package com.example.weldinspection.domain.model;
public record InspectionResult(
String jointId,
DefectPrediction prediction,
Severity severity
) {}
domain/model/ModelInferenceException.java
package com.example.weldinspection.domain.model;
// Domain-level exception — adapters throw this so the domain
// never sees ONNX- or HTTP-specific exceptions.
public class ModelInferenceException extends RuntimeException {
public ModelInferenceException(String message) { super(message); }
public ModelInferenceException(String message, Throwable cause) {
super(message, cause);
}
}
These are the types the entire application should speak. When a REST controller receives a request, it translates that external representation into a WeldImageSample. When an ML adapter returns an inference result, it translates it into a DefectPrediction. That translation belongs at the boundary — always at the boundary, never inside the domain.
This separation matters because it keeps the domain model focused on business meaning rather than transport formats, framework objects, or provider-specific payloads. Controllers, adapters, and integration code are responsible for converting between the outside world and the core’s language. The domain itself should not carry that burden. That principle aligns closely with the DDD idea that the model should preserve a clear and consistent domain language across the system (Evans, Domain-Driven Design).
Define the Driven & Driving Ports
A driving port defines how the outside world is allowed to enter the domain. REST controllers, Kafka consumers, scheduled jobs, and tests should all reach the core through such ports. The interface must express the system’s capabilities in domain terms only.
domain/port/in/InspectWeldUseCase.java
package com.example.weldinspection.domain.port.in;
import com.example.weldinspection.domain.model.InspectionResult;
import com.example.weldinspection.domain.model.WeldImageSample;
public interface InspectWeldUseCase {
InspectionResult inspect(WeldImageSample sample);
}
This is also why one interface per use case is often the stronger choice. A controller should depend on InspectWeldUseCase, not on WeldInspectionService. That keeps the boundary stable even when the implementation behind it changes. The internal design can be refactored, business rules can be revised, and service classes can be replaced without forcing changes into the adapter layer.
Driven ports are interfaces defined by the domain itself, even though their implementations live elsewhere. They express what the core needs from the outside world without tying that need to any specific technology. In other words, the domain may say, “something must be able to classify an image,” but it should never say, “this requires an ONNX runtime.”
That distinction is fundamental. It allows the domain to remain focused on capabilities rather than technical products, libraries, or protocols. The moment an infrastructure detail appears in the port itself, the abstraction has already started to collapse. A well-designed, driven port keeps the dependency visible but expresses it in the domain language rather than in the language of tools and frameworks. That is very much in line with Cockburn’s original inside-versus-outside view of Hexagonal Architecture and with the DDD principle that the model should be shaped around domain meaning rather than technical mechanics (Cockburn; Evans).
domain/port/out/DefectClassificationPort.java
package com.example.weldinspection.domain.port.out;
import com.example.weldinspection.domain.model.DefectPrediction;
import com.example.weldinspection.domain.model.WeldImageSample;
// The ML boundary. The domain calls this; adapters implement it.
// Notice: no ONNX import, no RestTemplate import, no framework import.
public interface DefectClassificationPort {
DefectPrediction classify(WeldImageSample sample);
}
domain/port/out/InspectionAuditPort.java
package com.example.weldinspection.domain.port.out;
import com.example.weldinspection.domain.model.DefectPrediction;
import com.example.weldinspection.domain.model.Severity;
// The persistence boundary. Same principle — no JPA, no JDBC.
public interface InspectionAuditPort {
void record(String jointId, DefectPrediction prediction, Severity severity);
}
Both interfaces use domain types for a simple reason: the boundary must remain technology-neutral. DefectPrediction and Severity are part of the domain model, so they can safely cross that boundary. Infrastructure types cannot.
That decision may seem small in code, but it has large architectural consequences. A migration from ONNX to a cloud inference service should require a new adapter, not a redesign of the port, not a rewrite of the service layer, and not a cascade of broken tests. When the boundary is defined in domain terms, the surrounding technology can change without dragging the core along with it.
The Domain Service
The domain service implements the driving port and depends on the driven ports. Its responsibility is straightforward: coordinate business rules such as severity thresholds, confidence cutoffs, and audit requirements — nothing more.
That is also why Spring annotations do not belong here. A domain service should not need a container in order to exist. It should be possible to construct it directly in a plain unit test and verify its behavior without framework startup, classpath scanning, or model files lying around in the test environment.
domain/service/WeldInspectionService.java
package com.example.weldinspection.domain.service;
import com.example.weldinspection.domain.model.*;
import com.example.weldinspection.domain.port.in.InspectWeldUseCase;
import com.example.weldinspection.domain.port.out.DefectClassificationPort;
import com.example.weldinspection.domain.port.out.InspectionAuditPort;
// No @Service, no @Component, no Spring imports.
// Spring wiring is handled entirely in BeanConfiguration.
public class WeldInspectionService implements InspectWeldUseCase {
private final DefectClassificationPort classifier;
private final InspectionAuditPort audit;
public WeldInspectionService(
DefectClassificationPort classifier,
InspectionAuditPort audit) {
this.classifier = classifier;
this.audit = audit;
}
@Override
public InspectionResult inspect(WeldImageSample sample) {
DefectPrediction prediction = classifier.classify(sample);
Severity severity = resolveSeverity(prediction);
audit.record(sample.jointId(), prediction, severity);
return new InspectionResult(sample.jointId(), prediction, severity);
}
// Business rule: confidence below threshold is always INCONCLUSIVE
// regardless of defect type. This rule belongs here, not in the adapter.
private Severity resolveSeverity(DefectPrediction p) {
if (p.confidence() < 0.60) return Severity.INCONCLUSIVE;
return switch (p.type()) {
case POROSITY -> Severity.HIGH;
case UNDERCUT -> Severity.MEDIUM;
case OVERLAP -> Severity.LOW;
case NONE -> Severity.OK;
};
}
}
This keeps testing fast and precise. A rule like resolveSeverity can be exercised in milliseconds, and when that rule changes, the failure should be limited to the tests that describe it. That is the real value of the boundary: infrastructure remains outside, while the business core stays easy to understand, easy to evolve, and easy to trust (Ford et al., Software Architecture: The Hard Parts).
Driving Adapter
A driving adapter exists to translate, not to decide. It converts external representations into domain types, calls the use-case port, and maps the result back into the format expected by the outside world. Business logic does not belong in the controller; only boundary translation does.
That is why HTTP DTOs should remain in the adapter layer. They reflect HTTP concerns, not domain meaning. The same boundary discipline applies to dependencies: the controller should depend on the use-case interface, never on a concrete service class. Once that rule is in place, internal implementations can be reorganized freely — split, merged, or replaced — without forcing changes in the controller. The adapter should be able to observe the capability, not the implementation behind it.
adapter/in/rest/InspectWeldRequest.java
package com.example.weldinspection.adapter.in.rest;
// HTTP wire format — NOT a domain type.
// byte[] is received as a Base64-encoded string in JSON.
// For production use, prefer multipart/form-data with MultipartFile.
public record InspectWeldRequest(
String jointId,
String cameraId,
byte[] imageData
) {}
adapter/in/rest/InspectWeldResponse.java
package com.example.weldinspection.adapter.in.rest;
public record InspectWeldResponse(
String jointId,
String defectType,
double confidence,
String severity,
String modelVersion
) {}
adapter/in/rest/WeldInspectionController.java
package com.example.weldinspection.adapter.in.rest;
import com.example.weldinspection.domain.model.*;
import com.example.weldinspection.domain.port.in.InspectWeldUseCase;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
@RestController
@RequestMapping("/api/v1/inspections")
public class WeldInspectionController {
// Depends on the USE CASE INTERFACE — not on WeldInspectionService directly.
// The controller has no idea which service implementation is behind this port.
private final InspectWeldUseCase inspectWeld;
public WeldInspectionController(InspectWeldUseCase inspectWeld) {
this.inspectWeld = inspectWeld;
}
@PostMapping
public ResponseEntity<InspectWeldResponse> inspect(
@RequestBody InspectWeldRequest request) {
// Translate: HTTP request → domain type
WeldImageSample sample = new WeldImageSample(
request.jointId(),
request.imageData(),
request.cameraId(),
Instant.now()
);
InspectionResult result = inspectWeld.inspect(sample);
// Translate: domain type → HTTP response
return ResponseEntity.ok(new InspectWeldResponse(
result.jointId(),
result.prediction().type().name(),
result.prediction().confidence(),
result.severity().name(),
result.prediction().modelVersion()
));
}
}
Driven Adapters
Driven adapters implement the driven ports and form the system’s infrastructure boundary. They are the only classes allowed to import technologies such as ONNX, RestTemplate, or JdbcTemplate. Their job is straightforward: convert infrastructure types into domain types, and domain types back into infrastructure calls — nothing more.
That limitation is intentional. It keeps infrastructure concerns exactly where they belong: outside the core. The adapter may know how to call a model runtime, an HTTP endpoint, or a database client, but it should not contain business rules or reshape the domain. Once that discipline is maintained, infrastructure can change freely while the core remains stable, readable, and testable.
ML Adapter — ONNX Runtime (production model)
adapter/out/ml/OnnxModelAdapter.java
package com.example.weldinspection.adapter.out.ml;
import ai.onnxruntime.*;
import com.example.weldinspection.domain.model.*;
import com.example.weldinspection.domain.port.out.DefectClassificationPort;
import java.nio.FloatBuffer;
import java.util.Map;
// No Spring annotations — instantiated and managed via BeanConfiguration.
public class OnnxModelAdapter implements DefectClassificationPort {
private static final int IMG_SIZE = 224;
private static final String[] LABELS = {"POROSITY","UNDERCUT","OVERLAP","NONE"};
private final OrtEnvironment env;
private final OrtSession session;
private final String modelVersion;
public OnnxModelAdapter(String modelPath, String modelVersion)
throws OrtException {
this.env = OrtEnvironment.getEnvironment();
this.session = env.createSession(modelPath);
this.modelVersion = modelVersion;
}
@Override
public DefectPrediction classify(WeldImageSample sample) {
try {
float[] tensor = preprocess(sample.imageData());
long[] shape = {1, 3, IMG_SIZE, IMG_SIZE};
OnnxTensor input = OnnxTensor.createTensor(
env, FloatBuffer.wrap(tensor), shape);
try (OrtSession.Result result = session.run(Map.of("input", input))) {
float[] scores = (float[]) ((OnnxTensor) result.get(0)).getValue();
int best = argmax(scores);
return new DefectPrediction(
DefectType.valueOf(LABELS[best]),
scores[best],
modelVersion
);
}
} catch (OrtException e) {
// Wrap infrastructure exception in a domain exception.
// The domain service never sees OrtException.
throw new ModelInferenceException("ONNX inference failed", e);
}
}
public void close() throws OrtException {
session.close();
env.close();
}
private float[] preprocess(byte[] imageData) {
return new float[3 * IMG_SIZE * IMG_SIZE];
}
private int argmax(float[] scores) {
int best = 0;
for (int i = 1; i < scores.length; i++)
if (scores[i] > scores[best]) best = i;
return best;
}
}
Persistence Adapter — Audit Trail
adapter/out/persistence/AuditDbAdapter.java
package com.example.weldinspection.adapter.out.persistence;
import com.example.weldinspection.domain.model.*;
import com.example.weldinspection.domain.port.out.InspectionAuditPort;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
public class AuditDbAdapter implements InspectionAuditPort {
private final JdbcTemplate jdbc;
public AuditDbAdapter(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public void record(
String jointId,
DefectPrediction prediction,
Severity severity) {
jdbc.update("""
INSERT INTO inspection_audit
(joint_id, defect_type, confidence, severity, model_version, recorded_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
jointId,
prediction.type().name(),
prediction.confidence(),
severity.name(),
prediction.modelVersion(),
Instant.now()
);
}
}
Connecting Everything in One Place
BeanConfiguration is the one class that sees the full picture. It reads the configuration values, decides which adapters to create, and connects them to the domain service. This keeps setup logic in one place. The rest of the code does not need to know how the parts are selected or wired together. In the writer’s view, this single composition point makes the system easier to read, test, and change.
config/BeanConfiguration.java
package com.example.weldinspection.config;
import com.example.weldinspection.adapter.out.ml.*;
import com.example.weldinspection.adapter.out.persistence.AuditDbAdapter;
import com.example.weldinspection.domain.port.in.InspectWeldUseCase;
import com.example.weldinspection.domain.port.out.*;
import com.example.weldinspection.domain.service.WeldInspectionService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.client.RestTemplate;
@Configuration
public class BeanConfiguration {
@Bean
public RestTemplate inferenceRestTemplate(
@Value("${app.model.connect-timeout-ms:2000}") int connectTimeout,
@Value("${app.model.read-timeout-ms:5000}") int readTimeout) {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(connectTimeout);
factory.setReadTimeout(readTimeout);
return new RestTemplate(factory);
}
@Bean(destroyMethod = "close")
@ConditionalOnProperty(name = "app.model.adapter", havingValue = "onnx")
public DefectClassificationPort onnxModelAdapter(
@Value("${app.model.path}") String modelPath,
@Value("${app.model.version}") String modelVersion) throws Exception {
return new OnnxModelAdapter(modelPath, modelVersion);
}
@Bean
public InspectionAuditPort auditDbAdapter(JdbcTemplate jdbc) {
return new AuditDbAdapter(jdbc);
}
@Bean
public InspectWeldUseCase weldInspectionService(
DefectClassificationPort classifier,
InspectionAuditPort audit) {
return new WeldInspectionService(classifier, audit);
}
}
Model Versioning Strategy
A port boundary solves the basic model swap problem. In production, your team faces a tougher question. Which adapter is active right now? How do you switch it without a full redeployment?
The versioning strategy below grows step by step. Each stage adds one new capability. Your team does not need to move to the next stage before it is ready. All three stages implement DefectClassificationPort. The domain service stays the same in every stage. Only the adapter and the configuration change. This separation follows the ports-and-adapters view described by Cockburn (2005).
Stage 1 — Version Tag in Configuration
The simplest step and the one you can adopt today. Externalise the model path and version identifier into Spring configuration. The adapter loads the correct artefact at startup. Rolling back to a previous model version is a configuration change and a pod restart — no code change, no rebuild.
application.yml
app:
model:
adapter: onnx
path: /opt/models/weld-classifier-v2.3.onnx
version: v2.3
BeanConfiguration.java — ONNX bean
@Bean(destroyMethod = "close")
@ConditionalOnProperty(name = "app.model.adapter", havingValue = "onnx")
public DefectClassificationPort onnxModelAdapter(
@Value("${app.model.path}") String modelPath,
@Value("${app.model.version}") String modelVersion) throws Exception {
return new OnnxModelAdapter(modelPath, modelVersion);
}
Each prediction carries its modelVersion into the audit trail through the modelVersion field on DefectPrediction. This creates a clear link between every past result and the exact model artifact that produced it.
Stage 2 — Shadow Mode (Validate Before Committing)
Before your team sends real production traffic to a new model version, run both models side by side. The live model returns its result to the caller. The candidate model runs in the background, and the system logs its prediction for later comparison. The business outcome stays unchanged because the caller always receives the result from the live model.
In the writer’s view, this is one of the safest ways to evaluate a new model in production. Your team gets real comparison data without exposing users or operations to unnecessary risk. This fits well with an architecture style that keeps change controlled and visible at the boundary between business logic and infrastructure (Cockburn, 2005).
adapter/out/ml/ShadowModelAdapter.java
package com.example.weldinspection.adapter.out.ml;
import com.example.weldinspection.domain.model.*;
import com.example.weldinspection.domain.port.out.DefectClassificationPort;
import com.example.weldinspection.domain.port.out.ShadowComparisonPort;
import java.util.concurrent.CompletableFuture;
// Implements the same port as OnnxModelAdapter and PythonRestModelAdapter.
// The domain service cannot tell the difference.
public class ShadowModelAdapter implements DefectClassificationPort {
private final DefectClassificationPort production; // current live model
private final DefectClassificationPort candidate; // model under evaluation
private final ShadowComparisonPort shadowLog;
public ShadowModelAdapter(
DefectClassificationPort production,
DefectClassificationPort candidate,
ShadowComparisonPort shadowLog) {
this.production = production;
this.candidate = candidate;
this.shadowLog = shadowLog;
}
@Override
public DefectPrediction classify(WeldImageSample sample) {
// Production result returned to domain service immediately.
DefectPrediction prod = production.classify(sample);
// Candidate runs asynchronously — never delays the caller.
CompletableFuture.runAsync(() -> {
try {
DefectPrediction shadow = candidate.classify(sample);
shadowLog.record(sample.jointId(), prod, shadow);
} catch (Exception e) {
// Candidate failures are logged, never propagated.
shadowLog.recordFailure(sample.jointId(), e.getMessage());
}
});
return prod;
}
}
domain/port/out/ShadowComparisonPort.java
package com.example.weldinspection.domain.port.out;
import com.example.weldinspection.domain.model.DefectPrediction;
// Driven port for persisting shadow comparison results.
// Back this with a DB adapter, Micrometer metrics, or a dedicated comparison service.
public interface ShadowComparisonPort {
void record(String jointId,
DefectPrediction production,
DefectPrediction candidate);
void recordFailure(String jointId, String reason);
}
BeanConfiguration.java — shadow wiring
@Bean
@ConditionalOnProperty(name = "app.model.adapter", havingValue = "shadow")
public DefectClassificationPort shadowModelAdapter(
@Value("${app.model.path}") String prodPath,
@Value("${app.model.version}") String prodVersion,
@Value("${app.model.shadow.path}") String shadowPath,
@Value("${app.model.shadow.version}") String shadowVersion,
ShadowComparisonPort shadowLog) throws Exception {
var production = new OnnxModelAdapter(prodPath, prodVersion);
var candidate = new OnnxModelAdapter(shadowPath, shadowVersion);
return new ShadowModelAdapter(production, candidate, shadowLog);
}
application.yml — shadow mode
app:
model:
adapter: shadow
path: /opt/models/weld-classifier-v2.3.onnx # production
version: v2.3
shadow:
path: /opt/models/weld-classifier-v3.0.onnx # candidate
version: v3.0
Once the shadow comparison shows an acceptable divergence rate, the team switches the adapter to the new model. In plain terms, the candidate model agrees often enough with the live model on real weld data. At that point, the rollout becomes simple. You change one configuration value, and the service starts using the new model.
Shadow mode answers a question offline validation never answers well. How does the new model behave on real production traffic, side by side with the current model, before any business decision depends on it? In practice, this provides stronger evidence and reduces rollout risk. It also fits the ports-and-adapters style, in which the application core remains stable while infrastructure choices change at the boundary (Cockburn, 2005).
Stage 3 — Registry-Driven Versioning (Runtime Switching)
The registry-based approach adds only two new elements to the domain. The first is ModelArtifact. The second is ModelRegistryPort. Both stay inside the domain package, and both stay free of infrastructure imports.
ModelArtifact is a small immutable value object. It describes a model your service is ready to deploy. In this case, it holds two pieces of information: the version identifier and the path to the model file on disk.
domain/model/ModelArtifact.java
package com.example.weldinspection.domain.model;
public record ModelArtifact(
String version,
String path,
String registeredAt
) {}
ModelRegistryPort defines what the application needs from the outside world. Given a model type, it returns the artifact marked as active. This keeps the responsibility clear. The domain states the need in domain language. The infrastructure provides the concrete result.
domain/port/out/ModelRegistryPort.java
// THE PORT — domain declares what it needs, names nothing concrete
public interface ModelRegistryPort {
ModelArtifact resolveActive(String modelType);
}
public record ModelArtifact(String version, String path) {}
In the writer’s view, this is a clean step forward. You add model selection without pulling registry logic into the core of your application.
VersionAwareModelAdapter holds the versioning logic in one clear place. It implements DefectClassificationPort, the same interface used by OnnxModelAdapter and PythonRestModelAdapter, so the domain service stays unchanged. This is a good example of keeping the core stable while the infrastructure changes around it, which fits the ports and adapters view described by Cockburn (2005).
adapter/out/ml/VersionAwareModelAdapter.java
// THE ADAPTER — resolves version at request time, not at startup
public class VersionAwareModelAdapter implements DefectClassificationPort {
private final ModelRegistryPort registry;
private final Map<String, OrtSession> sessionCache = new ConcurrentHashMap<>();
@Override
public DefectPrediction classify(WeldImageSample sample) {
ModelArtifact active = registry.resolveActive("weld-classifier");
OrtSession session = sessionCache.computeIfAbsent(
active.version(), v -> loadSession(active.path()));
float[] scores = runInference(session, sample.imageData());
return new DefectPrediction(bestLabel(scores), bestScore(scores),
active.version()); // ← version stamped on every prediction
}
}
Each time classify runs, the adapter asks the registry which model is active. It then checks a session cache keyed by the version string. If a session for that version already exists, the adapter reuses it. If the registry points to a new version, computeIfAbsent loads the new session on the first request that reaches it. Older sessions stay in the cache, though they stop receiving new traffic.
The version identifier from the registry is then written straight into the returned DefectPrediction. From there, it moves through the domain service into the audit record. You do not need extra tracing code to make this work. The design keeps version selection, runtime reuse, and traceability close together, while business logic stays clean.
DbModelRegistryAdapter handles the infrastructure side of the registry port. It runs one SQL query against the model_registry table and returns the row marked as active for the requested model type.
The @Cacheable annotation keeps the service from hitting the database on every inference request. Without this cache, a busy inspection line would query the registry table for each call and add needless load. The cache time to live, set in application.yml, defines the delay between an operator changing the active row and all running service instances picking up the new version.
adapter/out/ml/DbModelRegistryAdapter.java
// THE REGISTRY IMPLEMENTATION — one DB row controls what runs in production
@Cacheable("model-registry")
public ModelArtifact resolveActive(String modelType) {
return jdbc.queryForObject(
"SELECT version, path FROM model_registry WHERE type = ? AND active = true",
(rs, row) -> new ModelArtifact(rs.getString("version"), rs.getString("path")),
modelType
);
}
In most manufacturing settings, 30 seconds is a sensible starting point. In the writer’s view, this gives the team a good balance between fast rollout and stable operation. The design keeps registry access in the infrastructure layer and protects the domain from database details, which aligns with the ports-and-adapters approach described by Cockburn (2005).
The Model Changed. This Time, the Architecture Was Ready
The team now has a different experience. The data science team trains a better model, and the rollout stays simple. They package the new model as an ONNX artifact and register it in the model registry. Then they update the active row for weld-classifier to point to v3.0. On the next request, VersionAwareModelAdapter picks up the new artifact. No application code changes. No redeployment follows. No hotfix appears.
The audit trail records which model version scored each weld, together with the joint ID and the timestamp. When a quality engineer asks why weld joint J-4471 was flagged as high severity on a Tuesday afternoon, the answer sits one query away.
Before the switch, the team runs v3.0 next to v2.3 in shadow mode for two weeks. The divergence rate ends at 1.2 percent. The team accepts the result. The domain service does not need to change, and it does not even notice the swap.
Hexagonal Architecture does not reduce the complexity of machine learning systems. In the writer’s view, it puts complexity where it belongs. It keeps versioning, swapping, and runtime details inside adapters. It keeps business rules inside the domain. That split makes the system easier for you to test, for your team to change, and for you to trust in production.

This article is part of the JAVAPRO magazine issue:
From AI as a Feature to AI as Infrastructure
Move beyond AI experimentation and into AI engineering.
Explore the architectures, platforms, and operational practices required to build trustworthy AI systems at scale. From governance and observability to modern Java infrastructure, this edition examines the foundations of production-ready AI.
Discover the edition →