Skip to content

Architecture Overview

Gabor Galazzo edited this page Jul 25, 2026 · 1 revision

Architecture Overview

The single authoritative source for this material is .claude/ARCHITECTURE.md in the repository. This page is the navigable summary.


1. What Evento is

Evento implements the RECQ pattern: reactive, event-driven commands and queries. Two runtime components:

Component Role
Evento Server Central broker: bundle registry, event store, command routing, query routing, performance store
Evento Bundle Application unit hosting one or more domain components; connects to the server over TCP

Bundles register their handler types at startup. All inter-bundle traffic flows through the broker — bundles never talk directly to each other. That single choke point is what makes the whole system observable: the server sees every command, query and event, which is where the GUI's interaction graph, catalog and performance model come from.


2. Module map

evento-framework/
├── evento-transport-api/         Wire SPI: sealed Message records, Codec, Transport, state machine, reconnect
├── evento-transport-netty/       Netty impl: CBOR pipeline, chunking, TLS, heartbeat, backpressure
├── evento-common/                Shared domain types: annotations, messaging types, consumer SPI
├── evento-bundle/                Bundle runtime: client, consumer engines, component managers
├── evento-server/                Server runtime: bus lifecycle, event store, REST API, Spring Boot app
├── evento-consumer-state-store/
│   └── evento-consumer-state-store-jdbc/   JDBC impls (Postgres + MySQL) for the consumer SPIs
├── evento-gui/                   Web GUI (React, static assets)
├── evento-lab/                   In-process integration tests (single-bundle RTT, failure matrix, …)
└── evento-lab-microservices/     Multi-bundle integration tests (RECQ microservices scenario)
    ├── evento-lab-ms-api/
    ├── evento-lab-ms-command/
    ├── evento-lab-ms-query/
    ├── evento-lab-ms-saga/
    ├── evento-lab-ms-observer/
    └── evento-lab-ms-it/

Dependency direction. transport-api knows nothing about business types; transport-netty knows nothing about Evento domain concepts; evento-server knows nothing about the contents of a payload. Business classes are loaded only inside bundles.

Versions are centralised in the root build.gradle under allprojects { ext { … } }. As of 2.4.0: Java toolchain 25, Jackson 2.22.1, Netty 4.2.16.Final, Flyway 13.0.0, HikariCP 7.1.0, Micrometer 1.13.6; Spring Boot 4.1.0 is applied in evento-server/build.gradle.


3. The layers

Transport (evento-transport-api + evento-transport-netty)

Framing, codec, connection state, reconnect. See Wire Protocol.

Class Where Role
Transport api SPI: send(Message), sendRaw(byte[]), onFrame, onStateChange, close()
TransportServer api Server SPI: bind(port), onChildTransport, stop()
Frame api Parsed Message plus the retained raw wire bytes (enables zero-copy relay)
ConnectionStateMachine api AtomicReference-backed; only legal transitions permitted
ExponentialBackoffWithJitter api min(maxBackoff, base × 2^attempt) × (1 ± jitter); base 500 ms, max 30 s, jitter 0.2
MessageTypeRegistry api byte tag ↔ Class<? extends Message> whitelist
JacksonCborCodec api Default Codec implementation
InMemoryTransport api Test double; simulateDisconnect() yields DISCONNECTED
NettyClientTransport netty Client-side channel + reconnect
NettyServerTransport netty Accept loop; each accepted channel becomes a ServerChildTransport
NettyTransportConfig netty Optional SslContext (prepends SslHandler when set)

Server bus (evento-server, com.evento.server.bus.*)

Routing, correlation, registries, QoS. See Server Bus.

Bundle client (evento-bundle, com.evento.application.client.*)

Connection supervision, correlation tracking, inbound dispatch. See Bundle Client.

Consumer engines (evento-bundle, com.evento.application.consumer.*)

Projector / saga / observer run loops over five focused SPIs. See Consumer Engines.


4. Ten load-bearing design decisions

These are deliberate and interlocking. Undoing one usually breaks something several layers away, so they are not to be changed without discussion.

1. The server is payload-agnostic. Request / Response / Notification carry payloadType: String plus payload: byte[]. The server routes by string; bundles hold the business class loader and deserialize locally. No business class loading server-side — which is why the server can broker bundles it was never compiled against.

2. Sealed Message + MessageTypeRegistry. Adding a wire type means extending permits and registering a byte tag. The compiler then enforces exhaustive dispatch everywhere. There is no "accept anything under this package" escape hatch.

3. Two correlation maps, two purposes. CorrelationStore holds server-initiated requests with a CompletableFuture to await. ForwardingTable holds bundle-A → server → bundle-B relays — no future, it just remembers where the response goes. Disconnect drains via drainByDestination, not drainInvolving, so originator-side entries survive for reconnect delivery.

4. One event stream, sealed BusEvent. Replaces v1's four parallel listener lists. Subscribers pattern-match with switch expressions.

5. Lifecycle is explicit. BusLifecycle.start(port) / stop(Duration deadline); Spring's BusStarter owns @PostConstruct / @PreDestroy. Constructors do no work — no threads, no I/O.

6. ConnectionState.canSend() is true for CONNECTED and DEGRADED. DEGRADED is advisory backpressure from the Netty high-water mark; the TCP socket is alive. Responses are critical-path and must not be dropped because a channel is momentarily busy.

7. No System.exit anywhere. BundleClient.start() returns a CompletableFuture that fails with the rejection reason. The caller decides what to do about it.

8. Virtual-thread executors, not StructuredTaskScope, for long-running engines. The stable JDK 25 StructuredTaskScope API is owner-thread-scoped — close auto-joins — which does not fit an engine that starts in one method and stops in another. Short-lived batch fan-out inside a run() can still open a scope locally.

9. The framework does not manage instance lifecycle. It emits performance metrics only (PerformanceInvocationsMessage, PerformanceServiceTimeMessage). Scaling and instance lifecycle belong to the external orchestrator (k8s, Nomad).

10. MessageHandlerInterceptor methods are all default. All 24 of them — an interface-segregation fix, so implementors override only the hooks they need.


5. Command flow, end to end

The aggregate command path, brokered by CommandBrokerHandler (com.evento.server.es), which subscribes to BusEvent.BundleDiscovered and registers a LocalRequestHandler per AggregateCommandHandler and service CommandHandler payload type:

  1. Acquire the optional distributed lock (lockId ?? aggregateId)
  2. BrokerEventStore.fetchAggregateStory(aggregateId) → event stream plus optional snapshot
  3. Wrap DomainCommandMessage into DecoratedDomainCommandMessage (state + event stream attached)
  4. BusLifecycle.forward(...) to the aggregate's bundle
  5. BrokerEventStore.publishEvent(DomainEventMessage), plus saveSnapshot when due
  6. Return the stored event to the caller

The service command path is the same flow without decoration, storing a ServiceEventMessage.

BrokerEventStore is a four-method interface, which is what decouples CommandBrokerHandler from the concrete Spring JPA EventStore and lets integration tests supply an in-memory implementation.


6. Test strategy

Tests live at the boundary: integration tests use real TCP transports (NettyServerTransport + BundleClient), not mocks. Mocks appear only as the in-memory transport test double. Two harnesses support this — evento-lab for single-bundle scenarios and evento-lab-microservices for a full multi-bundle RECQ scenario. See Building and Testing.


See also

Clone this wiki locally