A tiny Spring Boot clone built from scratch in pure Java 25 — no Spring, no Tomcat, no Jackson, zero dependencies.
The goal is to demystify the framework. Annotations aren't magic, dependency injection isn't magic, and neither is routing. Under the hood it's reflection, a HashMap, and a for loop. This repo is the whole thing in ~500 lines you can read in one sitting.
void main() {
MiniSpring.run("com.amigoscode", 8080);
} __ __ _ _ ____ _
| \/ (_)_ __ (_) ___| _ __ _ __(_)_ __ __ _
| |\/| | | '_ \| \___ \| '_ \| '__| | '_ \ / _` |
| | | | | | | | |___) | |_) | | | | | | | (_| |
|_| |_|_|_| |_|_|____/| .__/|_| |_|_| |_|\__, |
|_| |___/
MiniSpring started in 59ms
Listening on http://localhost:8080
Components registered: 1
Controllers registered: 1
Write a controller exactly like you would in Spring Boot:
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
// Constructor injection — one constructor, no @Autowired needed
public UserController(UserService userService) {
this.userService = userService;
}
@Get
public List<User> getAllUsers() {
return userService.findAll();
}
}@Service
public class UserService {
public List<User> findAll() { ... }
}Then:
curl http://localhost:8080/api/v1/users[{"id":"d1c6a2a4","name":"Amigos","email":"amigos@code.com"}]- JDK 25 — the project uses compact source files and
java.lang.IO - Maven
Maven must run on JDK 25 too. If mvn picks up an older JDK, point it at the right one:
export JAVA_HOME=$(/usr/libexec/java_home -v 25) # macOSmvn compile exec:exec
curl http://localhost:8080/api/v1/users
exec:exec, notexec:java.Main.javais a compact source file, which compiles to a package-private class with an instancemain(). The in-process runner looks for apublic static void main(String[])and won't find one — only the realjavalauncher understands the JEP 512 protocol.
Five pieces, each one small enough to read in a few minutes.
| File | Role | Spring equivalent |
|---|---|---|
MiniSpring.java |
Scans the classpath, builds everything, starts the server | SpringApplication.run() |
di/Container.java |
Creates beans and injects dependencies | ApplicationContext |
Router.java |
Maps GET /path to a controller method |
DispatcherServlet |
WebServer.java |
Embedded HTTP server (JDK's HttpServer) |
Embedded Tomcat |
json/Json.java |
Serializes objects to JSON via reflection | Jackson |
Startup is five steps:
- Component scan — walk the package directory, load every
.classfile - Sort — split them into
@Serviceand@RestController - Wire — hand them to the container, which builds each bean and injects its dependencies
- Route — read
@RequestMappingoff each controller and@Getoff each method, build a route table - Serve — start the HTTP server and delegate every request to the router
They're just labels. Each one is a handful of lines with no behaviour of its own — the framework is what gives them meaning.
| Annotation | Applies to | Purpose |
|---|---|---|
@RestController |
class | Marks a class as an HTTP controller |
@RequestMapping |
class | Base path shared by every route in the controller |
@Get |
method | Maps a method to GET, appended to the base path |
@Service |
class | Marks a class as a managed bean |
@Autowired |
field, constructor | Marks an injection point |
The container follows Spring's own rules for picking a constructor:
- A constructor annotated
@Autowiredwins - Otherwise, a single constructor is used with no annotation at all (Spring 4.3+)
- Otherwise, fall back to the no-arg constructor
Beans are created recursively — to build one, the container first builds everything its constructor asks for. Field injection via @Autowired still works, and circular dependencies are detected and reported with the chain (A -> B -> A) instead of blowing the stack.
src/main/java/
├── Main.java # compact source file, unnamed package
└── com/amigoscode/
├── app/ # the demo application
│ ├── User.java
│ ├── UserController.java
│ └── UserService.java
└── framework/ # MiniSpring itself
├── MiniSpring.java
├── Router.java
├── WebServer.java
├── annotations/
├── di/Container.java
└── json/Json.java
This is a teaching framework, not a production one. It supports GET only, matches paths by exact string equality (no path variables — @PathVariable exists but isn't wired up yet), serializes JSON one way, has no request bodies, no error handling beyond a blanket 500, and every bean is an eager singleton.
That's the point. Every omission is a feature you now know how to add.
MIT