Architecture Overview
Mercury Composable is a Java 21 framework for building event-driven applications from self-contained, stateless functions wired together by YAML-configured event choreography. It targets microservices, serverless deployments, and any system where maintainability and horizontal scalability are priorities. Because each function is a pure input-process-output unit with an explicit interface contract, the framework is also well suited for AI-assisted code generation — an AI agent can generate correct functions, flow configurations, and REST definitions from this page alone.
System Architecture
A Mercury Composable application is structured in five distinct layers that process a request from outside in.
The outermost layer is the flow adapter. Adapters convert external requests into internal events.
The built-in HTTP flow adapter intercepts HTTP requests routed through the REST automation engine and
packages them as EventEnvelope objects destined for the flow engine, then delivers the final response
back to the caller. The Kafka adapter provides the same contract for stream-based event sources. The
adapter API is open, so custom adapters can consume any event source (serverless triggers, file watchers,
MQ listeners). Each adapter exposes a named route — the HTTP adapter uses the route http.flow.adapter.
REST Automation sits at the HTTP boundary and eliminates controller boilerplate. HTTP endpoints are
declared in a rest.yaml configuration file. Each entry maps a URL pattern and HTTP method set to
either a flow (via the flow key, routing through the HTTP flow adapter) or directly to a named function
(via service). REST automation handles CORS headers, per-endpoint authentication functions, request and
response header rules, distributed tracing activation, and timeout enforcement — all in configuration.
The Event Manager (also called the flow engine) is the core orchestrator. When an event arrives from an adapter, the event manager resolves the matching flow configuration by its ID, creates a transient in-memory state machine for that transaction, and begins executing the task sequence. For each task, the event manager performs the input data mapping (populating the function's argument scope from the state machine and request dataset), dispatches the event to the target function, collects the result, and applies the output data mapping (writing result values back to the state machine or flow output). Exception handling — per-task or per-flow — is also managed by the event manager.
The in-memory event system is the transport backbone, built on Eclipse Vertx's event bus with
Java 21 virtual thread management. All inter-function communication within a single JVM travels through
this bus. Point-to-point delivery routes an event to exactly one worker instance of the target function.
Broadcast delivery sends an event to all registered instances. The event system uses /tmp as an
overflow buffer when a consumer is slower than the producer, removing the need for explicit back-pressure
handling in user code.
Composable functions are the innermost layer and the only place where application business logic lives. A function knows nothing about HTTP, the flow configuration, or other user functions. It receives typed input, executes its logic, and returns typed output. Its only permitted external dependency is a platform or infrastructure component consumed through the event system — never a direct method call to another user function.
Composable Functions
A composable function is a Java class implementing TypedLambdaFunction<I, O> or the untyped
LambdaFunction. The typed interface defines the exact contract:
public interface TypedLambdaFunction<I, O> {
O handleEvent(Map<String, String> headers, I input, int instance) throws Exception;
}
LambdaFunction is equivalent to TypedLambdaFunction<Object, Object> and is used when input type
cannot be determined statically. Prefer TypedLambdaFunction with a concrete PoJo or
Map<String, Object> as the input type for all new functions.
The @PreLoad annotation registers the function with the event system at startup:
@PreLoad(route = "v1.get.profile", instances = 50, isPrivate = false)
public class GetProfile implements TypedLambdaFunction<Map<String, Object>, Profile> {
@Override
public Profile handleEvent(Map<String, String> headers, Map<String, Object> input, int instance) {
String profileId = headers.get("profile_id");
// business logic — return result or throw AppException(statusCode, message)
return profile;
}
}
The route is a lowercase, dot-separated identifier (e.g., v1.get.profile). At least one dot is
required. The instances value sets the maximum number of concurrent workers for this function.
isPrivate = false makes the function addressable by other application instances through the service
mesh; the default is true (local only). Optional @PreLoad parameters include envInstances
(read instance count from application.properties at startup), customSerializer, inputPojoClass,
and inputStrategy / outputStrategy for snake-case or camel-case serialization control.
Functions must be stateless. They must not share mutable state via static fields or ThreadLocal
variables, and must never call other user functions directly. All inter-function communication goes
through PostOffice. This isolation is what enables independent unit testing, safe parallel execution,
and hot-deployment of individual functions.
Throw AppException(statusCode, message) to return structured error responses with HTTP-compatible
status codes.
Event Envelope
Every message in the framework is transported as an EventEnvelope — an immutable container with three
distinct parts: metadata (routing address, trace ID, correlation ID, status code, execution timing),
headers (Map<String, String> of user-defined parameters passed to handleEvent), and body
(the event payload — a PoJo, Map<String, Object>, Java primitive, or byte[]).
Envelopes are serialized with MsgPack (binary JSON) inside the event bus and converted to standard JSON
at HTTP boundaries. User code never serializes directly. The framework maps the envelope body to the
typed input argument of handleEvent, and wraps the return value into a new envelope automatically.
EventEnvelope event = new EventEnvelope()
.setTo("v1.get.profile")
.setHeader("profile_id", "100")
.setBody(requestMap);
EventEnvelope response = po.request(event, 5000).get();
Profile profile = response.getBody(Profile.class);
The status field uses HTTP-compatible codes; getStatus() >= 400 indicates an error. Fluent setter
methods (setTo, setHeader, setBody, setStatus, setTrace, setCorrelationId) all return
EventEnvelope for chaining. To inspect all envelope metadata from inside a function, declare
the input type as EventEnvelope in the TypedLambdaFunction signature.
For a complete listing of all EventEnvelope and PostOffice methods, see the
Event Envelope Reference.
Event Script and Flow Configuration
Event Script is a YAML DSL that represents an event flow as a data structure rather than as code. A flow file specifies which function executes first, how data is mapped between functions, and what to do on success or error. An entire transaction's orchestration logic lives in a YAML file that can be changed and redeployed without modifying Java code.
flow:
id: 'create-profile'
description: 'Create a user profile with field encryption'
ttl: 30s
exception: 'v1.hello.exception'
first.task: 'v1.encrypt.fields'
tasks:
- input:
- 'input.body -> *'
- 'text(address, telephone) -> protected_fields'
process: 'v1.encrypt.fields'
output:
- 'result -> model.profile'
description: 'Encrypt PII fields before storage'
execution: sequential
next:
- 'v1.save.profile'
- input:
- 'model.profile -> *'
process: 'v1.save.profile'
output:
- 'text(application/json) -> output.header.content-type'
- 'result -> output.body'
description: 'Persist profile and return to caller'
execution: end
Input/output data mapping uses an origin -> destination syntax with namespaces. Every expression
in the input: and output: lists is a mapping statement. The namespaces are:
input.— the incoming request dataset. For HTTP:input.body,input.header.<name>,input.path_parameter.<name>,input.query.<name>,input.method,input.uri,input.sessionmodel.— the per-transaction state machine, readable and writable by all tasks in the flow. Usemodel.parent.(alias:model.root.) to share state with sub-flows.result— the entire return value of the just-executed taskresult.<key>— a specific field from the return value (uses dot-bracket notation)output.body— the final response body returned to the calleroutput.header.<name>— a response headeroutput.status— the HTTP response status codeheader.<name>— a key-value passed into the next function'sheadersargumenterror.status,error.message,error.task,error.stack— available in exception handlerstext(value)— a string constant (also used for content-type, e.g.,text(application/json))int(n),long(n),float(n),double(n),boolean(true|false)— typed constantsmap(k=v, ...)ormap(config.key)— a map of key-values or values from application config
The wildcard -> * maps the entire source object as the function's input body. Dot-bracket notation
(model.user.address, numbers[1]) is used throughout for nested access.
Execution types control flow advancement after each task: sequential continues to the next
task list; end terminates the flow and delivers the result; response sends the HTTP response
immediately and continues executing remaining tasks asynchronously; fork-n-join dispatches to
multiple tasks in parallel and waits for all; parallel dispatches without waiting; decision
evaluates the task's boolean return to branch; pipeline chains tasks as a streaming pipeline;
sink discards the result and continues without waiting.
Sub-flows are referenced using the flow:// protocol: process: 'flow://my-sub-flow'. The parent
TTL must cover the combined execution time of all sub-flows.
REST Automation
HTTP endpoints are declared in rest.yaml without writing Java controllers:
rest:
- service: "http.flow.adapter"
methods: ['GET', 'POST']
url: "/api/profile/{profile_id}"
flow: 'get-profile'
timeout: 10s
cors: cors_1
headers: header_1
tracing: true
authentication: 'v1.auth.validator'
When flow is specified, the HTTP flow adapter packages the request and passes it to the flow engine.
When service points directly to a function route (without flow), requests go to that function
without a flow configuration. Path template parameters ({profile_id}) are accessible in flows as
input.path_parameter.profile_id. The optional authentication field names a function that receives
an AsyncHttpRequest and returns true to approve, false for HTTP 401, or throws
AppException(statusCode, message) for custom rejections.
Function Execution Model
Virtual threads are the default execution environment for all functions. Java 21 virtual threads
are cooperatively scheduled, cheap to create in large numbers, and suspend non-destructively on
blocking calls. A po.request(event, timeout).get() call appears sequential in code but the
virtual thread is suspended while waiting, freeing the JVM to schedule other work. This means
sequential, readable code behaves with the performance characteristics of reactive code, without
the callback complexity.
For functions that make blocking calls incompatible with virtual threads — tight CPU-bound loops
or legacy code using kernel-thread-specific constructs — add @KernelThreadRunner:
@PreLoad(route = "v1.heavy.computation", instances = 5)
@KernelThreadRunner
public class HeavyTask implements TypedLambdaFunction<Map<String, Object>, Map<String, Object>> {
// executes in kernel thread pool (configurable via kernel.thread.pool, default 100)
}
Keep instances small for kernel-thread functions (5–10 is typical). The default kernel thread
pool is capped at 100 (kernel.thread.pool in application.properties). Avoid the synchronized
keyword and ThreadLocal in virtual-thread functions; both create contention that undermines the
cooperative scheduling model.
Functions can also return Mono<T> or Flux<T> from Project Reactor. The framework integrates
these reactive return types automatically. For Flux, the consumer function receives stream
coordinates in headers (x-stream-id, x-ttl) and processes the stream via FluxConsumer<T>.
Core APIs
PostOffice is the messaging client. Always obtain it from handleEvent's headers and instance
arguments to preserve the distributed trace chain:
PostOffice po = PostOffice.trackable(headers, instance);
Key methods: po.send(route, body) — fire-and-forget; po.request(event, timeoutMs).get() — blocking
RPC on virtual thread; po.asyncRequest(event, timeoutMs) — async RPC returning a Vert.x Future;
po.eRequest(event, timeoutMs) — async RPC returning CompletableFuture; po.asyncRequest(events, timeout)
— fork-n-join parallel requests; po.sendLater(event, futureDate) — scheduled delivery;
po.broadcast(route, body) — send to all instances of a function. The po.exists(route) method can
discover public functions in other application instances when running in service mesh mode.
Platform is the singleton service registry:
Platform platform = Platform.getInstance();
platform.register("my.dynamic.function", new MyFunction(), 5); // dynamic registration
platform.hasRoute("v1.get.profile"); // check if function is registered locally
platform.release("my.dynamic.function");
platform.waitForProvider("cloud.connector", 10); // wait up to 10s for a provider to be ready
AsyncHttpRequest is the typed input class for functions declared directly as REST endpoints without
a flow. It provides accessors for all HTTP request fields. FluxConsumer\<T> wraps Flux streams
returned by other functions. AppConfigReader provides runtime access to application.properties
and application.yml values via config.get("my.key") and config.getProperty("my.key").
Distributed Architecture
Mercury scales beyond a single JVM through two complementary mechanisms. Event over HTTP allows
functions in separate application instances to communicate by exposing a built-in POST /api/event
endpoint (event.api.service). A caller routes an event to a function in a peer instance using
po.asyncRequest(event, timeout, headers, "http://peer/api/event", true) — the same EventEnvelope
serialization crosses the network boundary transparently. In Kubernetes, the event API endpoint is
reached via internal cluster DNS without requiring an ingress.
Minimalist Service Mesh uses Kafka as a distributed routing table and event bridge. Setting
cloud.connector=kafka in application.properties enables the cloud.connector module, which
publishes public-function routes to the distributed registry and bridges inter-instance events
through Kafka. Functions with isPrivate = false become reachable by any instance in the mesh.
Calling code uses the same PostOffice API whether the target is local or remote.
Key Annotations Reference
| Annotation | Purpose |
|---|---|
@PreLoad |
Register a function at startup: route, instances, isPrivate, envInstances, customSerializer, inputStrategy, outputStrategy |
@MainApplication |
Mark the application entry point class (implements EntryPoint); sequence controls order (1–999) |
@BeforeApplication |
Initialization hook that runs before @MainApplication; use sequences 3–999 for user code |
@KernelThreadRunner |
Execute the function in the kernel thread pool instead of virtual threads |
@EventInterceptor |
Receive the raw EventEnvelope as input body; return value is ignored (advanced routing patterns) |
@ZeroTracing |
Suppress distributed tracing for this function |
@WebSocketService |
Register a WebSocket endpoint handler; annotated class implements LambdaFunction |
@CloudConnector |
Mark a class as a cloud connector plug-in selected by cloud.connector in application.properties |
@CloudService |
Mark a class as a cloud service plug-in selected by cloud.services in application.properties |
@OptionalService |
Conditionally load a function based on a configuration expression (e.g., !feature.flag) |
For complete parameter details, combination rules, and required interfaces, see the Annotations Reference.
Key Configuration Files
| File | Purpose |
|---|---|
application.properties / application.yml |
Port (rest.server.port), component scan (web.component.scan), flow list reference (yaml.flow.automation), cloud connector (cloud.connector), serialization strategy (snake.case.serialization) |
rest.yaml |
Declarative HTTP endpoint definitions: URL, methods, flow or service route, CORS config, auth function, tracing |
flows.yaml |
Index of individual flow YAML files to load (flows: list; optional location: for non-classpath paths) |
*.yml in flows/ |
Individual event flow configurations, each defining one transaction's complete task sequence |
All configuration files are loaded from the classpath (classpath:/) in src/main/resources by
default. File-system paths can be specified using the file:/ prefix, and multiple files can be
comma-separated (e.g., yaml.rest.automation=file:/tmp/config/rest.yaml, classpath:/rest.yaml).
Further Reading
- Methodology — design principles: input-process-output, zero dependency, event choreography, platform abstraction
- Getting Started — hands-on walkthrough with the composable example application
- Function Execution Strategies — virtual vs. kernel threads, Mono/Flux, authentication functions
- REST Automation — complete
rest.yamlsyntax reference - Event Script Syntax — complete flow DSL reference including all task types, data mapping, sub-flows, and preload overrides
- API Overview — full
PostOffice,Platform,EventEnvelope, and configuration API reference - Build, Test and Deploy — CI/CD, packaging, and deployment patterns