Migrating an Established Java Service to Micronaut
Migrating an existing Java service to Micronaut is not primarily about replacing REST annotations. The more difficult work is preserving established business behavior while removing assumptions tied to the previous runtime: CDI container access, classpath composition, security wiring, request-scoped context, and HTTP-client behavior.
In our case, an established domain API needed to operate alongside existing platform APIs in a single Micronaut server. The goal was to retain resource-management, workflow, and job-execution behavior while allowing the host platform to remain responsible for authentication, authorization, persistence, and request context.
Define a Runtime Boundary
Business logic that directly depends on a specific dependency-injection container becomes difficult to host in another framework.Legacy services commonly resolve implementations through CDI-specific constructs such as Instance<T> or multi-instance handlers. Those constructs should not be invoked from business logic that is expected to run under Micronaut. Instead, runtime-specific behavior should be isolated behind a small, stable adapter.
// Pseudocode
interface DomainRuntime {
ResourceContext createResourceContext(Connection connection);
WorkflowContext createWorkflowContext(RequestScope scope);
Result validate(Connection connection);
}
@Singleton
final class PlatformDomainRuntime implements DomainRuntime {
// Uses Micronaut-managed dependencies,
// platform persistence, and request context.
}
Controllers and domain services call the stable runtime interface. The adapter owns framework-specific construction and selection of implementations.
This approach avoids a broad rewrite of business logic while making the host runtime explicit.
Avoid Duplicate Implementations
A frequent migration failure is having more than one implementation of the same shared class on the runtime classpath. This can happen when a shared library is introduced while an older, copied implementation remains in the application source tree.
The application may compile successfully but fail at runtime with errors such as:
- NoSuchMethodError
- ClassCastException
- bytecode verification failures
- incompatible parameter or context types
The practical rule is:
| Keep one canonical implementation of every shared contract on the runtime classpath.
Where a shared connector or client library is adopted, application code should call it through an adapter if necessary, rather than retain a competing copy of the same classes.
Select HTTP Verbs at Execution Time
HTTP integration bugs can be deceptive. A high-level call to post(...) does not guarantee that the actual network request is a POST.
One migration issue involved an invocation object that was created with a default GET method. Calling post(entity) later supplied a request body but did not change the stored method. The external service received a GET request and responded with a misleading “resource not found” error.
The HTTP client should choose the request method at execution time.
// Pseudocode
HttpResponse get() {
return execute("GET", null);
}
HttpResponse post(HttpEntity entity) {
return execute("POST", entity);
}
HttpResponse execute(String method, HttpEntity entity) {
HttpRequest request = buildRequest(method, entity);
return send(request);
}
This small design decision prevents a whole category of failures in resource creation, validation, and workflow execution.
Reuse the Platform RBAC Model
A domain API hosted by the platform should participate in the platform’s existing authentication and authorization model rather than create a parallel security mechanism.
// Pseudocode
@Controller("/domain")
@Secured(SecurityRule.IS_AUTHENTICATED)
final class WorkflowController {
@Get("/workflows")
@RequiresPermission("WORKFLOW:READ")
List<Workflow> list(
@Header("X-Workspace-ID") String workspaceId) {
return workflowService.list(workspaceId);
}
}
The request token identifies the authenticated user and associated groups. The platform authorization layer maps those groups to application permissions. The controller declares the required permission for each operation.
This gives all APIs consistent security behavior and keeps permission assignment in one place.
Treat Request Context as an Explicit Dependency
A resource, workflow, or job is usually meaningful only within a platform scope. Tenant, workspace, branch, user identity, and permissions should be carried consistently from the controller to business and persistence layers.
// Pseudocode
RequestScope scope = RequestScope.of(
tenantId,
workspaceId,
branch,
authenticatedUser);
return runtime.withScope(scope,
() -> workflowService.create(request));
Making context explicit prevents accidental use of global state and supports multi-tenant, versioned, or workspace-scoped behavior.
Key Takeaways
A successful Micronaut migration makes ownership boundaries clear:
- Micronaut owns application startup, dependency injection, HTTP routing, and request lifecycle.
- The host platform owns authentication, RBAC, persistence, and tenant or workspace context.
- Shared libraries own reusable business and integration behavior.
- Adapters isolate runtime-specific behavior.
- A single canonical implementation prevents classpath conflicts.
- HTTP clients must preserve the verb and payload intended by the caller.
The result is not merely an application that starts under Micronaut. It is an application that preserves existing workflows while behaving consistently within the platform’s security, context, and operational model.
Comments
Post a Comment