Blog

From Orchestration To Choreography

Nano services act as connectors that let application behavior move through explicit events instead of one central orchestrator.

NanoNative TeamSeptember 18, 20265 min read
Nano runtime connected to HTTP, metrics, logs and file watching services
NanoServicesArchitecture

The word service is overloaded in Java. In many applications it means business logic. In others, it describes technical capabilities. In Nano, a service is closer to a connector: it connects the runtime to HTTP, logging, metrics, files, databases, observability tools or anything else the application needs to talk to.

That distinction matters because it keeps the runtime explicit. A service is added when the application needs the capability. If an app needs HTTP and metrics, it starts those services. If it only needs events and a small amount of internal logic, it does not pay during runtime for everything else just because it exists on the classpath.

The connector idea is what makes Nano feel different from a controller-first framework. A request, a configuration change, an error, a metric update and a service reply can all move through the same runtime vocabulary. Services subscribe to the events they understand, translate them into concrete work and publish the next signal when another part of the application should continue.

Startup Is The Runtime Contract

A Nano application tells you a lot about itself at startup. The services passed to Nano are the runtime surface area. That is different from a classpath-scanning model where the application shape is discovered by searching for annotations across all dependencies.

final Nano nano = new Nano(
    new HttpServer(),
    new MetricService(),
    new LogService()
);

You can read this and know what the process is trying to do. It works with HTTP events, metric events and logging events. There may be other classes behind those services but the application boundary stays clear only when each service remains responsible for the capability it owns.

Connectors Keep Boundaries Honest

A connector (service) should know how to talk to a capability. It should not slowly absorb unrelated domain behavior. For example, an HTTP-facing service can translate an incoming request into an application event. A domain class can decide whether that request is valid. Inside a Service subclass, the event handling method can stay narrow. In real code, app-specific events like EVENT_ORDER_ACCEPTEDshould be registered as typed channel constants.

@Override
public void onEvent(final Event<?, ?> event) {
    event.channel(EVENT_HTTP_REQUEST).ifPresent(httpEvent -> httpEvent.payloadOpt()
        .filter(HttpObject::isMethodPost)
        .filter(request -> request.pathMatch("/orders"))
        .map(this::readOrder)
        .map(policy::validate)
        .ifPresent(order -> context()
            .newEvent(EVENT_ORDER_ACCEPTED, () -> order)
            .send()));
}

The service owns HTTP and event translation. OrderPolicy owns the decision. That means the policy can be tested without Nano, without HTTP and without a running server.

HTTP Is Just One Event Source

When HttpServer receives a request, Nano publishes it on the HTTP request channel. A service can inspect that channel, read the request and decide whether it owns the path. The request does not need to be converted into a controller method before the application can handle it.

@Override
public void onEvent(final Event<?, ?> event) {
    event.channel(EVENT_HTTP_REQUEST).ifPresent(httpEvent -> httpEvent.payloadOpt()
        .filter(HttpObject::isMethodGet)
        .filter(request -> request.pathMatch("/orders"))
        .ifPresent(request -> request.createResponse()
            .body(Map.of("orders", orderView.loadOpenOrders()))
            .respond(httpEvent)));
}

The boundary is visible. HTTP is a connector into the runtime, not the whole application model. The same service listens to all events of the same type but chooses which ones to serve.

Services React To The Runtime

A Nano service is still a class. It can subscribe to channels, publish events and react to configuration changes.

@Override
public void onEvent(final Event<?, ?> event) {
    event.channel(EVENT_ORDER_CREATED)
        .map(Event::payloadAck)
        .ifPresent(order -> context()
            .newEvent(EVENT_AUDIT_WRITE, () -> order)
            .send());
}

Non-Blocking Does Not Remove Design Work

Nano can keep the runtime responsive because services communicate through events and the runtime can use modern Java concurrency primitives underneath. That does not mean application code becomes concurrent by accident. Each service still needs to be written as code that can safely run while other services are doing their own work.

The practical rule is to avoid hidden shared state. A subscriber should treat the event payload as the input for one unit of work. If it needs mutable state, that state should have a clear owner. If it needs to call a slow dependency, the service should make that boundary obvious and publish a result event when the slow work completes.

@Override
public void onEvent(final Event<?, ?> event) {
    event.channel(EVENT_ORDER_LOOKUP_REQUESTED)
        .map(Event::payloadAck)
        .map(repository::findById)
        .ifPresent(order -> context()
            .newEvent(EVENT_ORDER_LOOKUP_COMPLETED, () -> order)
            .send());
}

The service has one job: connect a lookup request to the repository and publish the result. It does not store the current order in a field. It does not rely on the next request arriving after the previous one has finished. That shape is boring in the best way because it survives concurrency.

Async Flow Still Needs Clear Replies

Events help because not all work belongs on the direct request path. A request can validate input, enqueue background work and return an accepted response. Another service can listen for that work, call a database or external system and publish a completion event later.

The point is not to make every small operation asynchronous just because events exist. If the caller needs a direct response, keep that path direct. If the work should continue later, publish an event named as a fact that happened, such as order.created or invoice.email.requested. That keeps the system readable when there are many subscribers.

Services Can Be Small On Purpose

A common failure mode in service-style code is the all-purpose service. It handles transport, validation, persistence, logging, retries and metrics. That looks convenient until the class becomes the only place where behavior can be changed.

Nano does not force a particular layering style but explicit services make smaller boundaries natural. One service can connect to HTTP. Another can watch files. Another can expose local observability. Those services can communicate through events and each one can be removed from startup when it is not needed.

Smaller services also help when tuning runtime behavior. A service that accepts traffic can be measured separately from a service that writes audit records. A service that calls an external system can own its retry and timeout behavior without spreading those details through the HTTP layer. The event boundary makes those decisions explicit.

Business Logic Should Stay Portable

The connector idea discourages putting every rule inside a runtime service. A pricing rule, validation rule or domain calculation should be testable without starting HTTP, metrics or a file watcher. The service can translate events into calls to those plain classes.

@Override
public void onEvent(final Event<?, ?> event) {
    event.channel(EVENT_ORDER_REQUESTED)
        .map(Event::payloadAck)
        .map(policy::approve)
        .ifPresent(result -> context()
            .newEvent(EVENT_ORDER_REVIEWED, () -> result)
            .send());
}

That shape keeps the runtime integration thin and the business rules portable. The Nano service connects to the event system. The plain Java class decides.

Why This Matters For Tests

Explicit services make tests smaller. A test for HTTP behavior can start HttpServer and the service under test. A test for domain behavior can skip Nano entirely and test the plain Java class. When the runtime is explicit, tests do not inherit half the application by accident.

final Nano testApp = new Nano(
    Map.of(CONFIG_LOG_LEVEL, TEST_LOG_LEVEL),
    new HttpServer(),
    new OrderApiService(new OrderPolicy())
);

The test does not need to boot a full container. It does not need to accept unrelated auto-configuration. The services in the constructor are the services in the test.

Debugging Starts With The Channel

The debugging loop changes too. Instead of asking which annotation registered a method, start with the channel. Was an event published? Did a subscriber match it? Did the payload have the expected type? Did the service respond on the same event or publish a follow-up event?

That sequence prevents a lot of guessing. A connector service has a visible subscription and a visible output. When something does not run, there are fewer hidden framework paths to inspect.

Nano Services Are Connectors

A lot of older service design grew around orchestration. One central layer knew the whole workflow, called each dependency in order and became responsible for every branch in the system. That can work for simple request-response applications but it becomes fragile as more capabilities are added.

Nano points in the other direction. Services publish and react to events. HTTP, metrics, logs, configuration, developer tools and application behavior can each connect to the runtime without requiring one object to orchestrate everything. The flow is choreographed through explicit event names and service subscriptions.

As NanoNative grows beyond one framework artifact, new capabilities can join the ecosystem as services that listen, publish and expose their own boundary. Nano stays small at the center while the surrounding tools cooperate through the runtime model.