ZeroZ Stack · The full-stack framework

Why does it take four languages to move one object?

In a conventional Java web application a single field travels from a Java class, through a JSON encoder, across HTTP, into a TypeScript interface and finally onto a DOM node, and at every one of those boundaries the compiler stops being able to help you. ZeroZ Stack does not try to make those boundaries cheaper, it removes them, so the whole trip happens in Java.

Artifactcom.zeroz4j:zerozstack-*
RuntimeJava 21 · Jakarta EE · WebAssembly
LicenceApache 2.0

The cost

The two boundaries you pay for every day.

The network mismatch

The Java object is converted into JSON text, sent over HTTP, and then parsed back into a JavaScript or TypeScript object in the browser. Nothing checks that the two ends still agree, so if a field is renamed on the server you usually find out from a user.

The UI mismatch

The browser DOM can only be changed from JavaScript or TypeScript, so even a pure Java team ends up with a second language, a second build tool chain and a second set of libraries, just for the screen.

What this looks like in practice

Rename a field on the server and nothing breaks, not at compile time and not at deploy time. It breaks in the browser, for a user, as undefined. Add a nullable field and three separate artefacts have to agree about it, i.e. the entity, the DTO and the TypeScript interface, and usually one of them gets forgotten. And if you ask an AI agent to add a column end to end, it has to hold four languages in context at once, which is generally where it starts inventing endpoints that do not exist.

The solution

The object is never re-encoded.

A ZeroZ Stack client is compiled Java that runs as WebAssembly in the browser. It calls an ordinary Java interface, the call travels as packed binary over a persistent WebSocket, and it arrives at a CDI bean on the server as the same object with the same type. There is no controller, no deserialiser and no mapping layer, basically because there is nothing left to map between.

01 · How it works

A full-stack feature in four files.

You write a model class, a service interface, its implementation and a UI class, all in Java and all sharing the exact same object. There is no HTTP mapping, no JSON translation and no ORM schema to write, and the four files below are the complete feature (a small chat message), not an abbreviated version of it.

SHARED · MODEL · 1 · ChatMessage.java
// becomes serializable + persistable
@DataModel
public class ChatMessage implements BinaryPackable {
    private String author;
    private String text;

    public ChatMessage() {}
    // constructor, getters, setters…
}
SHARED · API · 2 · ChatService.java
// the RPC contract — one interface
@RmiService
public interface ChatService {
    void sendMessage(ChatMessage msg);
}
SERVER · 3 · ChatServiceImpl.java
@ApplicationScoped
public class ChatServiceImpl implements ChatService {
    @Inject ZeroZDbNode db;

    @Override
    public void sendMessage(ChatMessage msg) {
        // receives the exact object sent — persist it
        db.localDb().write(ctx -> {
            root.getMessages().add(msg);
            ctx.store(root.getMessages());
        });
    }
}
CLIENT · WASM · 4 · ChatView.java
public class ChatView extends Div {
  ChatView(ChatService chat) {
    Button b = new Button("Send");
    b.onClick(e -> {
      // calls the backend over binary WS
      chat.sendMessage(new ChatMessage("Alice", "Hi!"));
    });
    add(b);
  }
}

→ scaffold the multi-module project with the Maven archetype, then mvn clean install and open http://localhost:8080

02 · The model

What the annotations do.

I want to…
The mechanism
What it means
Send this object over the wire
@DataModel
A binary serialiser for the class is generated at compile time, so no reflection is needed (WebAssembly handles reflection badly).
Call the server
@RmiService on an interface
A client-side Wasm stub and the server-side dispatch are both generated from this one interface, so you simply call the interface.
Keep an object current
@LiveSync
The object stays identical on the server and in every client that holds it. You fetch it once and after that it is patched in place, so there is no watching, subscribing or polling to write.
Restrict who can call it
@Secured / @RolesAllowed
Authorisation sits on the method, next to the call, rather than in a gateway that somebody has to keep in step with the code.
React to state in the UI
ValueSignal / Computed / Effect
Dependency-tracked reactivity inside the Wasm heap: a signal holds state, a computed value derives from it, and an effect re-runs whenever something it read has changed.
Persist it
The object graph is the database, so there is nothing more to do.

@LiveSync is about state synchronisation and signals are about reactivity, and it helps to keep the two apart. The first one keeps the object true on both sides, and the second one decides what the user interface does about a change. If you bind a synchronised object to a signal, a change made on the server redraws the UI without you writing any glue code.

Why binary

Why the wire is binary and not JSON.

The client and the server talk to each other in a dense binary RPC protocol over a persistent WebSocket. This is not a compressed JSON, the text layer is simply gone, and with it the parsing, the repeated field names and the runtime type checks.

Dimension
JSON over REST
ZeroZ4j binary
Encoding
Verbose UTF-8 text
Dense packed bytes
Typing
Untyped — validated at runtime
Statically typed end to end
CPU cost
Serialize + parse both ends
Direct read/write, no parse step
Payload size
Field names repeated per object
No field names on the wire
Contract drift
Silent until it breaks in the browser
Caught by the compiler
Reflection
Required by most JSON libraries
None — serializers generated at build

Under the hood

Ahead of time, and without reflection.

Performance in the browser is decided at compile time rather than hoped for at runtime.

Annotation processing

During the Maven build an annotation processor scans for @DataModel and @RmiService and generates the _Serializer and _Stub classes. There is no runtime reflection, which matters because WebAssembly handles reflection poorly.

TeaVM WasmGC

The client module is transpiled from Java bytecode, including the generated stubs and serialisers, directly into WasmGC, which then runs in the browser.

Cooperative coroutines

Wasm runs on the single UI thread of the browser, so it cannot block. An RMI call therefore suspends the coroutine, sends its frame, hands control back to the browser, and resumes exactly when the response arrives.

Virtual threads (Loom)

Every incoming WebSocket frame is handed to a virtual thread, so the server's I/O threads never block on a long query and thousands of persistent connections stay responsive.

Modular backend

The core is a transport-agnostic CDI engine and RMI dispatcher, and a separate server binding supplies the HTTP and WebSocket transport.

Zero server-side DOM state

Unlike Vaadin, ZeroZ4j keeps no DOM state on the server at all. Components live entirely in the client's Wasm heap, so a user session costs the server nothing except its connection.

Deployment

Embedded, or hosted.

The same application code runs in three ways. In embedded mode the store is private to the JVM and there is no socket in the path. In auto-server mode the first JVM to start owns the store and serves it, and the others become clients, without any configuration deciding which is which. Or you point every JVM at a dedicated database server and none of them owns data. The application code does not change between the three, only the mode does, so you can start small and move later.

ZeroZ DB — what each mode guarantees →