Blog

What Happens After You Send an Event in Nano?

Follow an order-status request from its HTTP handler to the Nano event reply and see when async dispatch makes sense.

NanoNative TeamOctober 29, 20265 min read
Nano dispatch diagram showing event metadata, acknowledgment stopping propagation and caller-thread versus asynchronous virtual-thread execution
NanoEventsNative Java

Say we're building an endpoint that returns an order's status. The HTTP handler passes the lookup to OrderService through an event and uses its reply. Let's follow that request from send() to the response, then see where async dispatch and other listeners fit.

A service and its event channels

OrderService keeps order statuses in a map. It handles two kinds of request: look up an order's status or replace it. Each has a typed channel and a reply:

import berlin.yuna.typemap.model.TypeMapI;
import org.nanonative.nano.core.model.Service;
import org.nanonative.nano.helper.event.model.Channel;
import org.nanonative.nano.helper.event.model.Event;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public final class OrderService extends Service {
    public record OrderLookup(String orderId) {}
    public record OrderStatusPut(String orderId, String status) {}
    public record OrderResult(String orderId, String status) {}
    public record OrderStatusWritten(String orderId, String status, boolean created) {}

    public static final Channel<OrderLookup, OrderResult> EVENT_ORDER_LOOKUP =
        Channel.registerChannelId(
            "ORDER_LOOKUP", OrderLookup.class, OrderResult.class);
    public static final Channel<OrderStatusPut, OrderStatusWritten> EVENT_ORDER_STATUS_PUT =
        Channel.registerChannelId(
            "ORDER_STATUS_PUT", OrderStatusPut.class, OrderStatusWritten.class);

    private final ConcurrentMap<String, String> statuses = new ConcurrentHashMap<>();

    @Override
    public void onEvent(final Event<?, ?> event) {
        if (event.containsEvent()) {
            return;
        }
        event.channel(EVENT_ORDER_LOOKUP)
            .ifPresent(lookup -> lookupOrder(lookup, statuses));
        event.channel(EVENT_ORDER_STATUS_PUT)
            .ifPresent(update -> putStatus(update, statuses));
    }

    private static void lookupOrder(
            final Event<OrderLookup, OrderResult> event,
            final ConcurrentMap<String, String> statuses) {
        final String orderId = event.payload().orderId();
        event.respond(new OrderResult(orderId, statuses.get(orderId)));
    }

    private static void putStatus(
            final Event<OrderStatusPut, OrderStatusWritten> event,
            final ConcurrentMap<String, String> statuses) {
        final OrderStatusPut update = event.payload();
        final boolean created = statuses.put(update.orderId(), update.status()) == null;
        event.respond(new OrderStatusWritten(update.orderId(), update.status(), created));
    }

    @Override public void start() {}
    @Override public void stop() { statuses.clear(); }
    @Override public void configure(final TypeMapI<?> changes, final TypeMapI<?> merged) {}
    @Override public Object onFailure(final Event<?, ?> failed) { return null; }
}

The containsEvent() check needs a little explanation. Our requests carry records such as OrderLookup but a Nano failure report wraps the event that failed. The event.channel(EVENT_ORDER_LOOKUP) call gives us a typed event and it can also find a matching event inside a failure report. We skip those reports here so a failed write doesn't accidentally run again. Error handlers can deal with the failure separately.

Next, we register our HTTP handlers:

new Nano(new OrderService())
    .context(OrderController.class)
    .subscribeEvent(HttpServer.EVENT_HTTP_REQUEST, OrderController::getOrder)
    .subscribeEvent(HttpServer.EVENT_HTTP_REQUEST, OrderController::putStatus)
    .runAwait(new HttpServer());

Both HTTP handlers receive request events so each checks the method and path before doing any work. Here's the GET handler:

public static void getOrder(final Event<HttpObject, HttpObject> httpEvent) {
    final HttpObject request = httpEvent.payload();
    if (!request.isMethodGet()
            || !request.path().matches("/orders/[^/]+")
            || !request.pathMatch("/orders/{orderId}")) {
        return;
    }

    final String orderId = request.pathParam("orderId");
    final Context work = httpEvent.context().newContext(OrderController.class);
    final var lookup = work.newEvent(OrderService.EVENT_ORDER_LOOKUP,
        () -> new OrderService.OrderLookup(orderId));

    lookup.send();
    final OrderService.OrderResult result = lookup.response();

    if (result == null) {
        request.createResponse().statusCode(500)
            .body("Order lookup did not respond").respond(httpEvent);
    } else if (result.status() == null) {
        request.createResponse().statusCode(404)
            .body("Order not found").respond(httpEvent);
    } else {
        request.createResponse().statusCode(200)
            .body(Map.of("orderId", result.orderId(),
                         "status", result.status()))
            .respond(httpEvent);
    }
}

Download the runnable example (Nano 2026.10.10, Java 21+) and run mvn compile exec:java from its directory. The store starts empty. Send a PUT to add a status, then GET the same order. Check the startup log for the port; it's normally 8080:

curl -i -X PUT http://localhost:8080/orders/purchase-1001/status \
  -H 'Content-Type: text/plain' --data 'PROCESSING'

curl -i http://localhost:8080/orders/purchase-1001

The PUT handler sends an OrderStatusPut event. Repeating the same PUT leaves the order with the same status. For this example, any valid status label is accepted and the data is lost when the process stops. Persistence and rules about allowed status changes would need more work.

Static handlers or services?

The HTTP handlers read a request, send an event and write a response. They have nothing to initialize or close so static subscriptions work well here. Keep validation in the handler that performs the write: subscribers have no guaranteed order so registering a validator separately won't ensure it runs first.

The map needs to survive between requests so it belongs to OrderService. A database pool would fit the same pattern: open it in start, close it in stop. Nano calls onEvent only while the service is ready. If an HTTP module later needs its own cache or configuration, it can become a service too.

There's one lifecycle detail to watch: direct subscriptions stay registered until you remove them. If you subscribe in start, keep the listener references and unsubscribe in stop. Otherwise a callback could try to use a resource you've already closed. Exceptions from these listeners go straight to EVENT_APP_ERROR, bypassing Service.onFailure.

We also pass request data into static helpers, following Nano's recommendation. This makes it easier to see what each helper uses and keeps request data out of service fields. As with instance methods, a local variable can still refer to a shared object. Every request here uses the same map so we use ConcurrentHashMap for concurrent reads and writes. Making a method static doesn't make that shared data thread-safe.

What happens during send()?

The GET handler reads lookup.response() right after send(). That works because dispatch is synchronous by default: the handlers run on the caller's thread and send() returns when dispatch finishes. If the lookup takes 200 ms, the HTTP request's virtual thread spends that time doing or waiting for the lookup.

Nano calls direct listeners first, then ready services. A call to respond(value) sets the reply and acknowledges the event, which stops dispatch unless broadcasting is enabled. Since listener order isn't guaranteed, give a lookup one responder. Calling acknowledge() on its own stops dispatch without supplying an answer.

This timing matters for HTTP. HttpServer checks for a response as soon as HTTP-event dispatch returns. If we move the reply into an async callback, the server could check before the reply exists. Keeping this lookup synchronous lets the handler finish its response in time.

Tracing a request

Identity and lifecycle information available on a Nano event
Field or methodMeaning
channel().id()The operation, shared across events.
context().traceId()The attached context's ID.
context().traceIds()Context ID and ancestry.
createdAtMonotonic construction time (System.nanoTime()).
isAcknowledged(), response(), error()Current handling state, result and failure.

Events using the same context share a trace ID. Our controller creates a child context for each request so we can follow the lookup back to its HTTP request. Channel IDs are assigned at registration so use channel names when comparing logs across restarts. If you also need to tell individual events apart within one context, add your own ID:

final var lookup = work
    .newEvent(OrderService.EVENT_ORDER_LOOKUP,
        () -> new OrderService.OrderLookup(orderId))
    .putR("eventId", UUID.randomUUID().toString());

lookup.send();

final long elapsedNs = System.nanoTime()
    - lookup.asLongOpt("createdAt").orElseThrow();

work.info(() -> "event={} channel={} trace={} handled={} elapsedNs={}",
    lookup.asString("eventId"),
    lookup.channel().name(),
    lookup.context().traceId(),
    lookup.isAcknowledged(),
    elapsedNs);

Here, eventId is our own field. The elapsed time starts when the event was created so it includes the time before and during dispatch.

When the caller can wait for a future

A scheduled job may be able to do other work while a lookup runs. For that caller, we can wrap Nano's response callback in a future:

public static CompletableFuture<OrderService.OrderResult> lookupAsync(
        final Context work, final String orderId, final Duration timeout) {
    if (timeout.isZero() || timeout.isNegative()) {
        throw new IllegalArgumentException("timeout must be positive");
    }
    final var response = new CompletableFuture<OrderService.OrderResult>();
    response.orTimeout(timeout.toNanos(), TimeUnit.NANOSECONDS);

    work.newEvent(OrderService.EVENT_ORDER_LOOKUP,
            () -> new OrderService.OrderLookup(orderId))
        .async(reply -> {
            if (reply == null) {
                response.completeExceptionally(
                    new IllegalStateException("Lookup acknowledged without a result"));
            } else {
                response.complete(reply);
            }
        })
        .send();

    return response;
}

The caller can use thenApply or thenCompose to work with the result. We add a timeout because an event might never receive a reply. A missing order still completes normally, with a null status. If a handler calls acknowledge() without a result, the callback receives null and our helper fails the future. If no callback arrives, the future times out. That timeout doesn't cancel dispatch.

Letting other listeners know

Suppose we want to record metrics whenever an order status is read. We already have the lookup result so we can send a separate notification for interested listeners. Declare a channel on OrderController:

public static final Channel<OrderService.OrderResult, Void> EVENT_ORDER_STATUS_READ =
    Channel.registerChannelId(
        "ORDER_STATUS_READ", OrderService.OrderResult.class, Void.class);

Then send it from the GET handler's successful-response branch:

work.newEvent(EVENT_ORDER_STATUS_READ, () -> result)
    .broadcast(true)
    .async(true)
    .send();

broadcast(true) lets dispatch continue even if a listener acknowledges the event. With async(true), dispatch runs on another virtual thread while HTTP continues. The listeners still run one after another and receive the same event. Our result record is immutable so each sees the same status snapshot. Nano doesn't collect their replies or persist the notification. For an audit that must survive a crash, you'd need to store it.

When a handler fails

So far, OrderService.onFailure returns null, which forwards exceptions to the application's error handlers. To handle a lookup failure inside the service, we can use the original event and its error():

@Override
public Object onFailure(final Event<?, ?> failed) {
    return failed.channel(EVENT_ORDER_LOOKUP)
        .map(lookup -> {
            lookup.context().error(lookup.error(),
                () -> "Order lookup failed; trace={}",
                lookup.context().traceId());
            lookup.acknowledge();
            return Boolean.TRUE;
        })
        .orElse(null);
}

Returning Boolean.TRUE tells Nano we've handled the failure. To send a result back to the caller, we would need to call respond(result) on the lookup event. Here we only acknowledge it so there's no result and our HTTP handler returns 500. That's different from an unknown order: a successful lookup returns an OrderResult with a null status, which the HTTP handler turns into 404.

Using the lookup outside HTTP

A scheduled job can send the same OrderLookup as the HTTP handler. If we later replace the map with a database, both callers can keep sending that request and reading an OrderResult. The storage work stays in OrderService.