This document explains how Java's Foreign Function & Memory (FFM) API fits naturally into a Hexagonal Architecture (Ports & Adapters) style—using this repository as the concrete example.
The key idea is simple:
Treat your native dependency (a C library) as an external system. Put it behind an outbound port and implement it with an FFM adapter. Keep all FFM types and lifetime rules inside the adapter.
Note: FFM integrates native libraries in-process (shared libraries like
.so/.dylib/.dll), not separate OS processes. Architecturally, it’s still “outside” your core—so it belongs behind a port.
In a typical hexagonal system:
- Primary / inbound adapters: HTTP controllers, CLI, schedulers (how the world calls you)
- Application/core: your use cases, policies, orchestration
- Secondary / outbound ports: interfaces the core uses to talk to the outside world (DB, queues, native libs)
- Secondary / outbound adapters: implementations of those ports (Postgres adapter, Kafka adapter, FFM adapter)
FFM is an implementation technology for secondary adapters.
It gives you a clean place for the hard constraints of native interop:
- memory lifetimes (
Arena) - pointer-like values (
MemorySegment) - buffer management and encoding rules
- error code /
errnomapping - native library loading and platform differences
All of that stays at the boundary, instead of leaking into your domain/application code.
simple-ffm-demo is deliberately structured as “teaching stack” layers. Mapped to hexagonal roles:
(Application code)
|
v
+----------------------+
| Port (Java API) | `SvcApi`
+----------------------+
|
v
+----------------------+
| Adapter (FFM wrapper)| `SvcNativeWrapper`
+----------------------+
|
v
+----------------------+
| Driver / Binding | `svc_h` (fallback or jextract)
+----------------------+
|
v
+----------------------+
| Native Library (C) | `svc.h` / `svc.c`
+----------------------+
Concrete code pointers:
- Port (Java-facing API):
simple-ffm-demo/demo-app/src/main/java/es/omarall/ffm/api/SvcApi.java - Adapter (FFM wrapper):
simple-ffm-demo/demo-app/src/main/java/es/omarall/ffm/wrapper/SvcNativeWrapper.java - Driver/binding:
- fallback binding:
simple-ffm-demo/bindings/src/main/java/es/omarall/ffm/nativeapi/svc_h.java - generated binding (when enabled):
simple-ffm-demo/bindings/target/generated-sources/jextract/.../svc_h.java
- fallback binding:
- Native library:
simple-ffm-demo/native-lib/src/main/native/svc.h
SvcNativeWrapper is the boundary that:
- allocates temporary native memory per operation (
Arena.ofConfined()) - translates native return codes into Java exceptions
- converts native strings/buffers into Java
String - owns and releases the opaque native context (
svc_create/svc_destroy)
The benefit of the port (SvcApi) is that callers don’t need to know any of those rules.
mymodbus is a production-oriented example where the ports/adapters split is more explicit.
There are two useful “port boundaries” in this module:
- User-facing API (primary port): what the rest of your application uses
mymodbus/src/main/java/es/omarall/mymodbus/api/ModbusClient.java
- Native backend port (secondary/outbound port): what the application layer uses to talk to “native Modbus”
mymodbus/src/main/java/es/omarall/mymodbus/nativeport/ModbusNative.java
This is the boundary that makes the native side swappable (real FFM adapter vs fake adapter).
- FFM adapter (real):
mymodbus/src/main/java-jextract/es/omarall/mymodbus/nativeport/LibModbusJextract.java- requires
JEXTRACT_BINto be set (jextract runs during every build) - uses jextract-generated bindings (
modbus_h) - keeps all FFM types internal, returns only Java arrays / throws typed exceptions
- requires
- Test adapter (fake):
mymodbus/src/main/java/es/omarall/mymodbus/support/FakeModbusNative.java- in-memory implementation of
ModbusNative - used for unit tests and for measuring “internal overhead” without I/O
- in-memory implementation of
ModbusSession (mymodbus/src/main/java/es/omarall/mymodbus/session/ModbusSession.java) is the orchestrator:
- enforces session rules (1 connection ↔ 1 in-flight request)
- implements retries/backoff/circuit breaker behavior
- splits large reads/writes into protocol-sized chunks
- integrates deadline propagation (
Deadlines) and chooses per-call timeout values
Notice what it does not do:
- it does not allocate
MemorySegment - it does not handle native library loading
- it does not depend on jextract types
That separation is the hexagonal win: the application logic is insulated from native interop mechanics.
ModbusClients (mymodbus/src/main/java/es/omarall/mymodbus/api/ModbusClients.java) shows a practical wiring approach:
- the default
openTcp(...)path tries to instantiate the jextract adapter by class name - if it’s not present, it fails with a clear message
- overloads allow injecting a
ModbusNativedirectly (ideal for tests or custom adapters)
This is a good pattern for hexagonal systems: the core depends only on the port interface, and your composition root decides which adapter to use.
With JNI, you often end up with:
- a Java API
- plus C glue code (JNI bindings)
- plus the actual C library
- plus additional build/packaging concerns
With FFM + jextract, the adapter stays in Java:
- low-level binding is generated from headers (or kept as a fallback in this repo)
- wrapper + error mapping + lifetimes are plain Java code
That makes hexagonal layering easier to keep strict (no mixed-language boundary layer).
Native interop problems often reduce to “who owns this memory and how long is it valid?”.
FFM forces you to answer that explicitly via Arena scoping. That aligns with hexagonal thinking:
- the adapter owns native memory lifetimes
- the port exposes only safe Java values (copied data)
If you make the native dependency a port (ModbusNative), you can:
- test your orchestration logic with a fake adapter (
FakeModbusNative) - reserve native integration tests for a smaller set of scenarios
- run unit tests without native toolchains or OS-specific libraries
Use these rules when defining the port that your FFM adapter implements:
-
No FFM types in the port API
- Don’t expose
MemorySegment,Arena, layouts, or “native handles”. - Return Java primitives/arrays/DTOs and throw typed exceptions.
- Don’t expose
-
Make timeout/deadline semantics explicit
- Either pass a
Duration(as inModbusNative) or bind deadlines via context (scoped values). - The adapter translates that into native timeout calls (e.g., libmodbus response timeout).
- Either pass a
-
Normalize errors
- Map
errno/rc/protocol exceptions into a small Java error taxonomy. - Keep raw codes as optional detail, not as the primary contract.
- Map
-
Define concurrency semantics in the contract
- Is the port implementation thread-safe?
- Are calls serialized per session/connection?
ModbusSessionenforces “one in-flight request per connection” explicitly.
-
Copy out results; don’t return views into native memory
- Treat segments as scratch space.
- Convert to
int[],boolean[],String, or DTOs before returning.
Native C APIs often use patterns that don’t map 1:1 to idiomatic Java:
- opaque pointers (
ctx*) - output parameters (
int* out) - caller-provided output buffers (
char* buf, size_t len) - error codes + global error state (
errno)
An adapter translates those shapes into Java-friendly port operations:
- opaque pointer → adapter-owned resource +
close() - output parameter → allocate locally + return value
- output buffer → “retry with a bigger buffer” and return
String - rc/errno → typed Java exception hierarchy
simple-ffm-demo shows these patterns in a tiny space; mymodbus shows them under real constraints.
- Article-style FFM walkthrough:
docs/ffm/article.md - Thread-safety considerations:
docs/ffm/thread-safety.md - Vector API complement:
docs/ffm/vector-api.md - Demo walkthrough:
simple-ffm-demo/docs/FFM_GUIDE.md - Deep-dive patterns:
docs/ffm/fundamentals.md - mymodbus overview:
mymodbus/README.md