DEV Community

Duvan Jamid Vargas
Duvan Jamid Vargas

Posted on

Spring Boot Takes 2 Seconds to Start. I Built a Java Framework That Starts in 0.4 (Building Ligero, Part 1)

Java's web story has a reputation problem. Not because the language is slow, but because the default path to a web service starts with a framework that scans the classpath, builds reflection metadata, and takes seconds to say "hello".

Ligero is my attempt at a different default: a lightweight Java web framework with a zero-dependency core, built for Java 21 virtual threads, where the wiring is plain code the compiler checks. This is the first post in a series about how it's built and, more importantly, why it's built that way.

Status, honestly: Ligero is pre-1.0 and not published to Maven Central yet. You build it locally with ./gradlew publishToMavenLocal. Spring Boot's ecosystem is unmatched and this series is not a "Spring is bad" story. It's a story about trade-offs I wanted to make differently.

Repository: ligero-framework/ligero ยท Docs: ligero-framework.github.io/ligero-docs

What it looks like

Ligero app = Ligero.create(8080);

app.get("/hello/{name}", ctx ->
    ctx.json(Map.of("message", "Hello, " + ctx.pathParam("name") + "!")));

app.start();
Enter fullscreen mode Exit fullscreen mode

That's a complete server. No annotations, no scanning, no application.yml required. The core depends on nothing except slf4j-api, and the default engine is the JDK's built-in HTTP server running each request on a virtual thread.

The three itches

Every framework is a bundle of opinions. These are the three that shaped Ligero.

1. Startup time and footprint are features

On the project's own benchmark harness (same products CRUD app in each framework, launched identically, 4-core machine, JDK 21), the numbers look like this:

Framework Startup (ms) Throughput (req/s) p50 (ms) Dist libs (MB)
Ligero (JDK engine) 429 13,049 2.8 3
Ligero (Jetty engine) 592 10,044 3.7 5
Spring Boot (MVC / Tomcat) 1,952 5,809 6.7 20
Javalin (Jetty) 624 10,937 3.1 8

Absolute numbers depend on your hardware; the shape is the point, and the harness is reproducible so you can run it yourself. (Post 4 tells the story of how the JDK engine used to look 13x slower than this, and why that was a bug in a default, not in the design.)

Why does startup matter? Faster feedback loops in development, cheaper scale-to-zero and cold starts in production, and a smaller surface to patch.

2. Wiring should be code, not magic

When a dependency is missing in a reflection-based container, you often find out at runtime, sometimes deep inside a request. In Ligero, the dependency container (Beans, about 170 lines) takes lambdas:

Beans beans = Beans.builder()
    .bind(DataSource.class,        b -> pgDataSource())
    .bind(ProductRepository.class, b -> new JdbcProductRepository(b.get(DataSource.class)))
    .bind(ProductService.class,    b -> new ProductService(b.get(ProductRepository.class)))
    .start();   // builds and validates the WHOLE graph here
Enter fullscreen mode Exit fullscreen mode

"Where does this come from?" is answered with Ctrl+click. A missing binding fails at startup, not in production. Post 3 goes deep on this.

3. Secure by default, observable by design

Every Ligero app starts with an OWASP-aligned baseline enabled automatically: security headers on every response and request hygiene that rejects null bytes, control characters and encoded path traversal before any handler runs. Observability isn't a plugin you remember to add: health checks, per-route metrics, structured access logs and W3C trace context are in the box, with vendor-neutral SPIs (OpenTelemetry adapter included). Posts 2 and 6 cover these.

One architecture, promoted, not mandated

Ligero doesn't force an architecture, but it promotes one: the familiar layered design, organized into feature modules, with explicit wiring.

flowchart LR
    H[HTTP] --> C[Controller]
    C --> S[Service]
    S --> R[Repository]
    R --> D[(Data)]
    subgraph Module["Feature module (e.g. Products)"]
        C
        S
        R
    end

Each layer depends on the one below it through an interface. That gives you swappable implementations (in-memory vs JDBC), easy fakes in tests, and, as we'll see in post 5, a devtools dashboard that can trace every call between layers.

There's also a CLI that scaffolds this structure and wires new pieces into the right module:

ligero new shop                       # modular app, one greeting module
ligero generate resource Product      # a whole CRUD slice, wired and registered
Enter fullscreen mode Exit fullscreen mode

Everything else is a module

The core stays small. Anything beyond routing, middleware, config and DI is an optional artifact: ligero-jdbc (SQL to records, no ORM), ligero-jpa, ligero-migrations (Flyway), ligero-validation, ligero-auth (JWT, CSRF, sessions), ligero-redis, ligero-openapi, ligero-otel, ligero-devtools, template adapters, and more. You add a module when you need it and carry nothing you don't.

What this series covers

  1. Why Ligero (this post)
  2. A router and middleware pipeline in plain Java
  3. Dependency injection without reflection
  4. The benchmark that lied: Nagle's algorithm and the engine SPI
  5. Seeing your architecture: the devtools dashboard
  6. Observability in a lightweight framework
  7. Production hardening: resilience, auth, native image and MCP

Try it

git clone https://github.com/ligero-framework/ligero && cd ligero
./gradlew publishToMavenLocal
Enter fullscreen mode Exit fullscreen mode

Then follow the Learning Path or browse ligero-examples, which includes layered, hexagonal, JPA, templates, YAML-profile and Redis-scaled apps. If something is wrong or missing, issues and PRs are welcome.

Next up: how a segment trie, a middleware chain and a secure baseline fit in a few hundred lines of Java.

Top comments (0)