First servlet, Foy container, Chappe transport: under fifty lines of Java, no mandatory web.xml, no runtime classpath scan.

Prerequisites

  • Java 25 (Temurin) and Maven 3.9.16.sdkmanrc is checked in at the repo root, run sdk env first.

  • Familiarity with the Jakarta Servlet 6.1 API.

  • For the CDI flavour: a Vauban BeanManager (see Vauban — getting started).

Maven coordinates

<dependencies>
    <dependency>
        <groupId>jakarta.servlet</groupId>
        <artifactId>jakarta.servlet-api</artifactId>
        <version>6.1.0</version>
    </dependency>
    <dependency>
        <groupId>io.vidocq.foy</groupId>
        <artifactId>foy-core</artifactId>
        <version>0.1.0-SNAPSHOT</version>
    </dependency>
    <dependency>
        <groupId>io.vidocq.foy</groupId>
        <artifactId>foy-chappe</artifactId>
        <version>0.1.0-SNAPSHOT</version>
        <scope>runtime</scope>
    </dependency>
    <!-- Optional CDI through Vauban -->
    <dependency>
        <groupId>io.vidocq.foy</groupId>
        <artifactId>foy-cdi-vauban</artifactId>
        <version>0.1.0-SNAPSHOT</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Hello world: an annotated Servlet

package io.example;

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet("/hello/*")
public class HelloServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        var name = req.getPathInfo() == null ? "world" : req.getPathInfo().substring(1);
        resp.setContentType("text/plain;charset=UTF-8");
        resp.getWriter().write("Hello, " + name + "!");
    }
}

The @WebServlet annotation is resolved at compile time by an APT. Zero classpath scan at startup.

Boot the container

The public Chappe-side API is FoyChappeBoot. It takes a BeanManager (CDI) and exposes a Chappe Handler to mount on a ChappeMountPoint.

import io.vidocq.foy.chappe.FoyChappeBoot;
import jakarta.enterprise.inject.spi.CDI;

void main() throws Exception {
    var mounted = FoyChappeBoot.builder()
            .beanManager(CDI.current().getBeanManager())
            .contextPath("/app")
            .sessionTimeoutSeconds(1800)
            .build()
            .orElseThrow(() -> new IllegalStateException("No @WebServlet discovered"));

    chappeMountPoint.mount(listener, mounted.mountPrefix(), mounted.handler());
    mounted.fireContextInitialized();
}

build() returns an empty Optional<Mounted> when the application declares no Servlet/Filter/Listener bean — the caller decides whether that’s an error or a degraded mode.

Annotations or web.xml?

Foy supports both and applies the standard metadata-complete semantics.

  • @WebServlet, @WebFilter, @WebListener, @MultipartConfig: discovered at compile time by APT.

  • WEB-INF/web.xml and META-INF/web-fragment.xml: read at startup by WebXmlParser and merged through WebAppDiscovery.

  • If metadata-complete=true, annotations are ignored (standard Servlet 6.1 behaviour).

Wiring with Chappe

The bridge is ChappeServletBridge, a Chappe Handler that:

  1. exposes the Chappe Request as HttpServletRequestImpl;

  2. exposes the Chappe Response as HttpServletResponseImpl;

  3. invokes the FilterServlet chain;

  4. returns the response through ServletOutputStreamImpl backed by Chappe streaming — no intermediate copy.

sdk env
./mvnw -ntp install -DskipTests
./mvnw test

Official Jakarta Servlet 6.1 TCK: ./run-official-tck-servlet6.1.sh --all from the foy repo root. The foy-tck module is intentionally out-of-reactor — see the TCK page.

Next steps

  • Usage recipes — filters, listeners, async, multipart, sessions, dispatchers, virtual hosts, CDI integration.

  • Concepts — Servlet vocabulary, what changes in 6.1.

  • Internals — request sequence, threading, container lifecycle.