Skip to content

Latest commit

 

History

History
240 lines (162 loc) · 9.14 KB

File metadata and controls

240 lines (162 loc) · 9.14 KB

An Hexagonal Architecture lens for Java FFM: Ports & Adapters for Native Libraries

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.


1) Where does FFM live in hexagonal terms?

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.

Why this perspective is useful

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 / errno mapping
  • native library loading and platform differences

All of that stays at the boundary, instead of leaking into your domain/application code.


2) simple-ffm-demo as a minimal hex example

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
  • Native library: simple-ffm-demo/native-lib/src/main/native/svc.h

What the adapter is responsible for (and why)

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.


3) mymodbus: the same pattern, scaled to a real library (libmodbus)

mymodbus is a production-oriented example where the ports/adapters split is more explicit.

3.1) Identify the port(s)

There are two useful “port boundaries” in this module:

  1. User-facing API (primary port): what the rest of your application uses
  • mymodbus/src/main/java/es/omarall/mymodbus/api/ModbusClient.java
  1. 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).

3.2) The adapters

  • FFM adapter (real): mymodbus/src/main/java-jextract/es/omarall/mymodbus/nativeport/LibModbusJextract.java
    • requires JEXTRACT_BIN to 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
  • 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

3.3) Orchestration stays “core-ish”

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.

3.4) Wiring: selecting the adapter

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 ModbusNative directly (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.


4) How FFM helps you write better adapters (vs JNI glue)

4.1) “Adapter code is just Java”

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).

4.2) Lifetimes become a first-class responsibility

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)

4.3) Testability by design

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

5) Designing outbound ports for native libraries (FFM-oriented rules)

Use these rules when defining the port that your FFM adapter implements:

  1. 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.
  2. Make timeout/deadline semantics explicit

    • Either pass a Duration (as in ModbusNative) or bind deadlines via context (scoped values).
    • The adapter translates that into native timeout calls (e.g., libmodbus response timeout).
  3. Normalize errors

    • Map errno/rc/protocol exceptions into a small Java error taxonomy.
    • Keep raw codes as optional detail, not as the primary contract.
  4. Define concurrency semantics in the contract

    • Is the port implementation thread-safe?
    • Are calls serialized per session/connection?
    • ModbusSession enforces “one in-flight request per connection” explicitly.
  5. Copy out results; don’t return views into native memory

    • Treat segments as scratch space.
    • Convert to int[], boolean[], String, or DTOs before returning.

6) “API shape” alignment: facades/adapters vs C constraints

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.


7) Further reading in this repo

  • 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