A tiny, zero-magic Java web framework. Annotation-based REST routing, a real dependency-injection container, JDBC + JMS modules, and first-class integration testing — all on embedded Tomcat, in a few hundred KB. No reflection-heavy startup cost, no XML, no app server.
Prozed is for people who want to understand every layer of their stack: it reads like Spring, but you can read all of it in an afternoon.
@Bean
@Controller(path = "/")
public class HelloController {
@GetRequest(value = "/hello/{name}", produces = ContentType.TEXT_PLAIN)
public String hello(@PathParam("{name}") String name) {
return "Hello, " + name + "!";
}
}public class Main {
public static void main(String[] args) {
try (ProzedServer server = new ProzedServer()) {
server.start(); // GET http://localhost:8080/hello/world -> "Hello, world!"
}
}
}- Why Prozed?
- Features
- Requirements
- Install
- Quick Start
- Controllers & Routing
- Parameter Binding
- Dependency Injection
- Bean Lifecycle Hooks
- Servlet Filters
- Scheduled Tasks
- Database (prozed-jdbc)
- Messaging (prozed-jms)
- Testing (prozed-test)
- Configuration Reference
- Packaging & Deployment
- Modules
- Building from Source
- Limitations
- Contributing
- License
| Prozed | |
|---|---|
| Startup | Embedded Tomcat, classpath scan — boots in well under a second |
| Footprint | Core has a handful of dependencies (Tomcat, Gson, SLF4J) |
| Learning curve | The whole framework is a small, readable codebase — no hidden proxies |
| DI | Field injection with compile-target-agnostic reflection, cycle detection |
| Modular | Pull in prozed-jdbc / prozed-jms only if you need them |
| Testing | Boot your real app in JUnit and hit it over HTTP, with optional auto-rollback |
If you've ever wanted a "Spring Boot, but I can actually read the source" — that's Prozed.
- 🚀 Embedded Tomcat HTTP server — no external container
- 🧭 Annotation-based routing on a radix tree (
@GetRequest,@PostRequest,@PutRequest,@DeleteRequest) - 🔌 Path, query, and JSON-body parameter binding with automatic type conversion
- 📦 Dependency-injection container with
@Bean/@Inject, cycle detection, and lifecycle hooks - 🧱 Servlet
Filtersupport for cross-cutting concerns (auth, CORS, etc.) - ⏰ Fixed-rate scheduled tasks on virtual threads, with per-task timeout and no-overlap guarantees
- 🗄️ Optional JDBC module: connection pooling, transactions, Flyway migrations
- 📨 Optional JMS module: send/consume messages with
@Listener(ActiveMQ) - 🧪 JUnit 5 integration testing that boots your real application, with optional per-test DB rollback
- 🔧
${VAR}/${VAR:default}environment-variable placeholders inprozed.properties— container-friendly config
- Java 21+
- Maven 3.9+ (or Gradle)
Prozed is on Maven Central under io.github.shaikezam — no extra repository or credentials needed. Add the core module:
<dependency>
<groupId>io.github.shaikezam</groupId>
<artifactId>prozed-core</artifactId>
<version>1.0.0</version>
</dependency>Add optional modules as needed:
<!-- JDBC: pooling, transactions, Flyway -->
<dependency>
<groupId>io.github.shaikezam</groupId>
<artifactId>prozed-jdbc</artifactId>
<version>1.0.0</version>
</dependency>
<!-- JMS: messaging with @Listener -->
<dependency>
<groupId>io.github.shaikezam</groupId>
<artifactId>prozed-jms</artifactId>
<version>1.0.0</version>
</dependency>
<!-- Integration testing -->
<dependency>
<groupId>io.github.shaikezam</groupId>
<artifactId>prozed-test</artifactId>
<version>1.0.0</version>
<scope>test</scope>
</dependency>Use the latest version shown in the Maven Central badge at the top of this page.
1. Add prozed.properties to src/main/resources:
web.service.port=8080
web.service.scan-package=com.example2. Write a controller:
package com.example;
import prozed.io.core.api.di.Bean;
import prozed.io.core.api.web.Controller;
import prozed.io.core.api.web.GetRequest;
import prozed.io.core.api.web.PathParam;
@Bean
@Controller(path = "/")
public class GreetingController {
@GetRequest(value = "/greet/{name}")
public Greeting greet(@PathParam("{name}") String name) {
return new Greeting("Hello, " + name + "!");
}
public record Greeting(String message) {}
}3. Start the server:
package com.example;
import prozed.io.core.api.web.ProzedServer;
public class Main {
public static void main(String[] args) {
try (ProzedServer server = new ProzedServer()) {
server.start();
}
}
}$ curl http://localhost:8080/greet/world
{"message":"Hello, world!"}
ProzedServer.start()blocks (it runs the Tomcat accept loop), andProzedServerimplementsCloseable, so a try-with-resources gives you a clean shutdown.
A controller is a @Bean annotated with @Controller(path = "..."). The full route of a handler is the controller path joined with the request annotation value (double slashes are collapsed):
@Bean
@Controller(path = "/api/v1")
public class UserController {
@Inject
private UserService userService;
@GetRequest(value = "/users/{id}") // GET /api/v1/users/{id}
public User getUser(@PathParam("{id}") int id) {
return userService.findById(id)
.orElseThrow(() -> new HttpException("User not found: " + id,
HttpServletResponse.SC_NOT_FOUND));
}
@PostRequest(value = "/users") // POST /api/v1/users
public int createUser(@PayloadParam User user) {
return userService.create(user);
}
@PutRequest(value = "/users/{id}") // PUT /api/v1/users/{id}
public void updateUser(@PathParam("{id}") int id, @PayloadParam User user) {
userService.update(user);
}
@DeleteRequest(value = "/users/{id}") // DELETE /api/v1/users/{id}
public void deleteUser(@PathParam("{id}") int id) {
userService.delete(id);
}
@GetRequest(value = "/health", produces = ContentType.TEXT_PLAIN)
public String health() {
return "OK";
}
}- Handlers return JSON by default (serialized with Gson).
- A
voidhandler sends an empty200 OK. - Set
produces = ContentType.TEXT_PLAINto return a string/number/boolean as plain text. - Throw
HttpException(message, statusCode)from anywhere in a handler to return a specific HTTP status with a JSON error body:{"error": "..."}.
| Situation | Status |
|---|---|
| Path not registered | 404 Not Found |
| Path exists but not for this HTTP method | 405 Method Not Allowed |
Unknown HTTP verb (e.g. PATCH) |
405 Method Not Allowed |
| Malformed JSON body / missing required primitive param | 400 Bad Request |
HttpException thrown by your code |
the code you pass |
| Any other uncaught exception | 500 Internal Server Error |
| Annotation | Binds | Example |
|---|---|---|
@PathParam("{name}") |
A URL path segment | /users/{id} → @PathParam("{id}") int id |
@QueryParam("name") |
A query-string value | ?page=2 → @QueryParam("page") int page |
@PayloadParam |
The JSON request body | @PayloadParam User user (one per method) |
HttpServletRequest |
The raw servlet request | HttpServletRequest request (no annotation) |
HttpServletResponse |
The raw servlet response | HttpServletResponse response (no annotation) |
⚠️ @PathParamvalue must include the braces and exactly match the route placeholder. For the route/users/{id}, bind with@PathParam("{id}")— not@PathParam("id").
Path and query values are converted to the parameter's type automatically: int, long, double, float, boolean, char, their boxed equivalents, and String.
The parameter type encodes whether the value is required:
- Primitive (
int,long, …): the value is required. A missing value returns400 Bad Request. - Boxed (
Integer,Long, …): the value is optional. A missing value is passed asnullfor you to handle.
// page is required (400 if absent); size is optional (null if absent)
@GetRequest(value = "/users")
public List<User> list(@QueryParam("page") int page,
@QueryParam("size") Integer size) {
int limit = size == null ? 20 : size;
...
}// GET /users/search?name=Bob&limit=10
@GetRequest(value = "/users/search")
public List<User> searchUsers(@QueryParam("name") String name,
@QueryParam("limit") Integer limit) {
return userService.search(name, limit); // limit is null when omitted
}$ curl 'http://localhost:8080/users/search?name=Bob&limit=10'
[{"id":2,"name":"Bob"}]
$ curl 'http://localhost:8080/users/search?name=Bob' # limit omitted -> defaulted in code
[{"id":2,"name":"Bob"}]Query strings are split on &, keys/values are URL-decoded, and blank entries are ignored. A value is bound to the parameter by name and converted to its declared type.
Need a header, the raw request, or fine-grained control over the response? Declare an HttpServletRequest and/or HttpServletResponse parameter — no annotation required. Prozed injects the live servlet objects. Mix them freely with @PathParam / @QueryParam / @PayloadParam.
// Read a request header
@GetRequest(value = "/whoami")
public String whoami(HttpServletRequest request) {
return request.getHeader("X-User");
}
// Write a response header + status directly
@GetRequest(value = "/download")
public void download(HttpServletResponse response) {
response.setHeader("Content-Disposition", "attachment; filename=data.csv");
response.setStatus(HttpServletResponse.SC_OK);
}
// Both, alongside a bound param
@GetRequest(value = "/users/{id}")
public User getUser(@PathParam("{id}") int id,
HttpServletRequest request,
HttpServletResponse response) {
response.setHeader("X-Trace-Id", request.getHeader("X-Trace-Id"));
return userService.findById(id).orElseThrow();
}Each parameter of a handler method must be one of: annotated with @PathParam, @QueryParam, or @PayloadParam, or typed as HttpServletRequest or HttpServletResponse. Prozed validates this at startup (when routes are registered) and fails fast with a clear message rather than throwing a 500 at request time:
@GetRequest(value = "/bad")
public String bad(int page) { ... } // ❌ startup error: "Unbound parameter 'page' in ... — add @PathParam, @QueryParam, @PayloadParam, HttpServletResponse or HttpServletRequest"
@GetRequest(value = "/ok")
public String ok(@QueryParam("page") int page) { ... } // ✅
@GetRequest(value = "/raw")
public void raw(HttpServletResponse response) { ... } // ✅ servlet params need no annotation
@GetRequest(value = "/health")
public String health() { ... } // ✅ zero-parameter handlers are always validAnnotate classes with @Bean and inject collaborators into fields with @Inject:
@Bean
public class UserService {
@Inject
private UserRepository userRepository; // resolved from the container
public Optional<User> findById(int id) {
return Optional.ofNullable(userRepository.findById(id));
}
}@Beanclasses are discovered by scanningweb.service.scan-package(and its sub-packages).- Injection is by field type — the injected type must itself be a
@Bean. Injecting a type that isn't a bean fails fast at startup with a clear message. - The container detects dependency cycles at startup and reports the offending class instead of stack-overflowing.
- Module beans (from
prozed-jdbc/prozed-jms) are registered automatically — see Packaging & Deployment for the one fat-jar caveat.
Add any of these public, no-argument methods to a bean and Prozed will call them at the right time. They are matched by name (no annotation needed); if absent, they're skipped.
⚠️ Hooks must be declared directly on the bean class. Methods inherited from a superclass or interface are not invoked, and a non-publicor argument-taking method of the same name is silently ignored.
| Method | When |
|---|---|
preInit() |
After the bean's @Inject dependencies are wired |
postInit() |
After all beans are constructed and wired |
preDestroy() |
On server shutdown |
postDestroy() |
After preDestroy, during shutdown |
@Bean
public class CacheWarmer {
@Inject
private UserRepository repository;
public void postInit() {
repository.warmCache(); // all beans are ready here
}
public void preDestroy() {
// flush / release resources
}
}Register standard Jakarta Filters for cross-cutting concerns (auth, logging, CORS). Wrap each in a FilterWrapper(name, urlPattern, filter) and add it before start():
public class Main {
public static void main(String[] args) {
try (ProzedServer server = new ProzedServer()) {
server.addFilter(new FilterWrapper("auth", "/admin/*", new AuthFilter()));
server.addFilter(new FilterWrapper("cors", "/*", new CorsFilter()));
server.start();
}
}
}public class AuthFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (request.getHeader("Authorization") == null) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
chain.doFilter(req, res);
}
}Prozed ships a small scheduler in core (no extra module). SchedulerContainer is registered as a bean automatically — @Inject it and register(...) your recurring jobs.
Unlike routing or lifecycle hooks, scheduling is not annotation-driven: there is no @Scheduled. You register tasks explicitly by handing the container a SchedulingTaskProperties. The natural place to do that is a bean's postInit() hook, once all beans are wired.
@Bean
public class ReportScheduler {
@Inject
private SchedulerContainer scheduler;
@Inject
private ReportService reportService;
public void postInit() {
// first run after one interval (5 min), then every 5 minutes, no timeout
scheduler.register(new SchedulingTaskProperties(
"nightly-report",
5, TimeUnit.MINUTES,
reportService::generate));
// wait 10s before the first run, then every 30s, interrupt a run if it exceeds 10s
scheduler.register(new SchedulingTaskProperties(
"sync-inventory",
10, TimeUnit.SECONDS, // initial delay
30, TimeUnit.SECONDS, // interval
10, TimeUnit.SECONDS, // timeout
inventoryService::sync));
}
}A record describing one job:
| Field | Meaning |
|---|---|
taskName |
Label used in logs |
initialDelay + initialDelayUnit |
Delay before the first run |
interval + intervalUnit |
Fixed-rate period between subsequent runs |
timeout + timeoutUnit |
Max run time; the execution is interrupted if it exceeds this. 0 = no timeout |
method |
The Runnable to execute |
Constructors, simplest to fullest:
// first run after one interval; no timeout
new SchedulingTaskProperties(String taskName, long interval, TimeUnit intervalUnit, Runnable method);
// explicit initial delay; no timeout
new SchedulingTaskProperties(String taskName, long initialDelay, TimeUnit initialDelayUnit,
long interval, TimeUnit intervalUnit, Runnable method);
// explicit initial delay + timeout
new SchedulingTaskProperties(String taskName, long initialDelay, TimeUnit initialDelayUnit,
long interval, TimeUnit intervalUnit,
long timeout, TimeUnit timeoutUnit, Runnable method);initialDelay and interval may use different TimeUnits. Register a task with scheduler.register(...) and it starts on its own schedule; tasks are stopped for you on server shutdown.
- Tasks don't overlap themselves — if a run is still going when the next interval arrives, that run is skipped, not queued.
- Timeout — with
timeout > 0, an overrunning run is interrupted. This stops work that responds to interruption (blocking I/O, JDBC query timeouts, code that checksThread.interrupted()); a pure busy-loop that never checks won't be cut off. - Clean shutdown — in-flight tasks are given a moment to finish (so open DB transactions can commit) before the server stops.
intervalmust be greater than 0.
Cache warming/refresh, polling an external system, cleanup/retention jobs, periodic health or metric emission, outbox draining — anything you'd reach for @Scheduled in Spring.
Add prozed-jdbc and a JDBC driver (e.g. H2, PostgreSQL). JdbcOperations is registered as a bean automatically — just @Inject it.
@Bean
public class UserRepository {
@Inject
private JdbcOperations jdbc;
public User findById(int id) {
return jdbc.selectOne(
"SELECT id, name FROM users WHERE id = ?",
rs -> new User(rs.getInt("id"), rs.getString("name")),
id);
}
public List<User> findAll() {
return jdbc.select(
"SELECT id, name FROM users",
rs -> new User(rs.getInt("id"), rs.getString("name")));
}
public int create(User user) {
return jdbc.update("INSERT INTO users (name) VALUES (?)", user.name());
}
public void replaceAll(List<User> users) {
jdbc.inTransaction(conn -> { // atomic: all or nothing
jdbc.update("DELETE FROM users");
for (User u : users) {
jdbc.update("INSERT INTO users (name) VALUES (?)", u.name());
}
return null;
});
}
}| Method | Purpose |
|---|---|
select(sql, RowMapper<T>, params...) |
Query → List<T> |
selectOne(sql, RowMapper<T>, params...) |
Query → single T (or null) |
update(sql, params...) |
INSERT/UPDATE/DELETE → affected rows |
execute(sql, ResultSetHandler<T>, params...) |
Raw ResultSet access |
inTransaction(JdbcCallback<T>) |
Run work in a single transaction (commit on success, rollback on exception). Nested calls join the outer transaction. |
Backed by tomcat-jdbc. Every pool setting is tunable via prozed.properties — see the Configuration Reference.
Set flyway.enabled=true and drop SQL files in src/main/resources/db/migration (V1__init.sql, V2__seed.sql, …). Migrations run automatically at startup before your beans serve traffic.
-- src/main/resources/db/migration/V1__init.sql
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL
);Add prozed-jms (ships with the ActiveMQ client). JmsOperations is registered automatically.
@Bean
public class NotificationService {
@Inject
private JmsOperations jms;
public void notifyUser(User user) {
// object is serialized to JSON automatically
jms.sendMessage(user, "user.events", DestinationType.QUEUE);
}
public void notifyUserCreated(User user) {
// message properties travel alongside the JSON body
jms.sendMessage(user, "user.events", DestinationType.QUEUE,
Map.of("eventType", "USER_CREATED"));
}
public void broadcast(String text) {
jms.sendRawMessage(text, "announcements", DestinationType.TOPIC);
}
}sendMessage(...) accepts a single object or a Collection<?> and serializes it to JSON with Gson.
sendRawMessage(...) sends a raw string.
DestinationType is QUEUE or TOPIC.
Each of these also has an overload taking a trailing Map<String, Object> properties, which sets JMS message properties (metadata carried in the message header, separate from the body) via JMSProducer.setProperty.
Allowed value types are Boolean, Byte, Short, Integer, Long, Float, Double, String, and null; any other type throws MessageFormatRuntimeException at send time.
Property names must not start with JMS — that prefix is reserved.
The properties map is optional. The existing 3-argument
sendMessage(...)/sendRawMessage(...)overloads are unchanged and set no properties; passingnullor an empty map has the same effect.
A listener is a @Bean that implements jakarta.jms.MessageListener and is annotated with @Listener:
@Bean
@Listener(destination = "user.events", destinationType = DestinationType.QUEUE)
public class UserEventListener implements MessageListener {
private static final Logger LOG = LoggerFactory.getLogger(UserEventListener.class);
@Override
public void onMessage(Message message) {
try {
String eventType = message.getStringProperty("eventType");
LOG.info("Received ({}): {}", eventType, message.getBody(String.class));
} catch (JMSException e) {
throw new RuntimeException(e);
}
}
}A class annotated with
@Listenermust also be a@Beanand implementMessageListener; Prozed fails fast at startup with a clear message otherwise.
By default, a TOPIC listener only receives messages published while it's connected — anything published while it's offline is lost. Set durable = true (plus a subscriptionName) to keep the backlog on the broker across restarts:
@Bean
@Listener(destination = "activity.topic", destinationType = DestinationType.TOPIC,
durable = true, subscriptionName = "activity-service-sub")
public class ActivityTopicListener implements MessageListener {
// ...
}Durable listeners require jms.client-id to be configured (see the config table below); the connection's client ID is derived as <jms.client-id>-<subscriptionName>, so multiple durable listeners in the same service don't collide. Prozed fails fast at startup if durable = true is set on a QUEUE destination, if subscriptionName is blank, or if jms.client-id isn't configured.
@ProzedTest boots your real application once per test class and lets you drive it over HTTP with HttpClientOperations.
@ProzedTest(mainClass = com.example.Main.class)
class UserControllerTest {
private final HttpClientOperations http = HttpClientOperations.createDefault();
@Test
void getsUser() throws Exception {
var response = http.sendAndDeserializeWithResponse(
http.request("/api/v1/users/1").GET().build(),
User.class);
assertEquals(200, response.statusCode());
assertEquals("Alice", response.body().name());
}
@Test
void createsUser() throws Exception {
String body = new Gson().toJson(new User(0, "Bob"));
var response = http.send(
http.request("/api/v1/users")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
}
}@ProzedTest options:
| Attribute | Purpose |
|---|---|
mainClass |
Your application entry point (required) |
mainArgs |
Arguments passed to main |
cleanUp |
When true and the prozed-jdbc test-jar is on the test classpath, each test runs inside a transaction that is rolled back afterward, so tests don't pollute the database |
The test port is read from the test classpath's
prozed.properties.
@ProzedTest boots the application but does not process @Inject on the test instance — annotating a test field does nothing and leaves it null. Drive the app over HTTP with HttpClientOperations for most cases. When a test needs a bean directly (for example, to publish a JMS message that has no HTTP entry point), pull it from the running container:
JmsOperations jms = (JmsOperations) ProzedServer.getContainer().get(JmsOperations.class);
jms.sendMessage(event, "issues.queue", DestinationType.QUEUE);getContainer() is available once the server has started (i.e. inside a @Test method), and get(Class) returns the same singleton the application uses — so writes still flow through cleanUp's rolled-back connection.
Database rollback is driven by TestJdbcOperations, which ships in the prozed-jdbc test-jar. The extension silently runs without rollback if that artifact is missing — every write commits and leaks into the next test. Add the test-jar (alongside prozed-test) to any module whose tests use cleanUp = true:
<dependency>
<groupId>io.github.shaikezam</groupId>
<artifactId>prozed-test</artifactId>
<version>${prozed.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.github.shaikezam</groupId>
<artifactId>prozed-jdbc</artifactId>
<version>${prozed.version}</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>TestJdbcOperations hands every query — including those run on Tomcat request threads during an HTTP call — a single shared, uncommitted connection, so cleanUp isolates HTTP-driven tests too.
All settings live in prozed.properties on the classpath.
Any value in prozed.properties may contain ${VAR} or ${VAR:default} placeholders. At startup Prozed replaces them:
- Environment variable
VAR, if set. - Otherwise the inline
default(the part after:), if provided. - Otherwise an empty string.
Resolution is environment-only (JVM -D system properties are not consulted) and runs once, non-recursively, when properties are loaded.
# ${DB_HOST} has no default — env var is required, else resolves to ""
db.url=jdbc:mariadb://${DB_HOST}:${DB_PORT:3306}/app_db
db.username=${DB_USER:root}
db.password=${DB_PASSWORD}This keeps secrets and per-environment values out of the packaged jar — ideal for containers, where Docker/Compose feed the variables:
# docker-compose.yml
issue-service:
image: eclipse-temurin:21-jre-alpine
env_file:
- .env # DB_HOST, DB_PASSWORD, ... injected into the container env
command: ["java", "-jar", "/app.jar"]# .env
DB_HOST=task-tracker-mariadb
DB_PASSWORD=root
# DB_PORT / DB_USER omitted — the ${...:default} in prozed.properties appliesA placeholder with no matching env var and no inline default resolves to an empty string, not an error — a value marked Required below will then fail later at the point it is used. Provide a default or ensure the variable is set.
See the containerized microservices example for a full working stack.
| Property | Description | Default |
|---|---|---|
web.service.port |
Embedded Tomcat port | 8080 |
web.service.scan-package |
Base package scanned for @Bean / @Controller |
Required |
| Property | Description | Default |
|---|---|---|
db.url |
JDBC URL | Required |
db.driver-class-name |
JDBC driver class | Required |
db.username |
DB user | Required |
db.password |
DB password | Required |
db.pool.initial-size |
Initial pool size | 2 |
db.pool.min-idle |
Minimum idle connections | 2 |
db.pool.max-idle |
Maximum idle connections | 5 |
db.pool.max-active |
Maximum active connections | 20 |
db.pool.max-wait |
Max wait for a connection (ms) | 10000 |
db.pool.test-on-borrow |
Validate connection on borrow | true |
db.pool.validation-query |
Validation query | SELECT 1 |
db.pool.validation-interval |
Validation interval (ms) | 30000 |
db.pool.remove-abandoned |
Reclaim abandoned connections | true |
db.pool.remove-abandoned-timeout |
Abandoned timeout (s) | 60 |
db.pool.log-abandoned |
Log abandoned connections | true |
flyway.enabled |
Run Flyway migrations at startup | false |
flyway.locations |
Migration scripts location | classpath:db/migration |
flyway.baseline-on-migrate |
Baseline an existing schema | true |
flyway.table |
Flyway history table | flyway_schema_history |
| Property | Description | Default |
|---|---|---|
jms.broker.type |
Broker type (e.g. activemq) |
— |
jms.broker.url |
Broker URL (e.g. tcp://localhost:61616) |
— |
jms.username |
Broker username | — |
jms.password |
Broker password | — |
jms.pool.max-connections |
Max pooled connections | 10 |
jms.pool.max-sessions-per-connection |
Max active sessions per connection | 500 |
jms.pool.idle-timeout |
Idle connection timeout (ms) | 30000 |
jms.redelivery.max-redeliveries |
Max redelivery attempts before a message is sent to the DLQ | 6 |
jms.redelivery.initial-delay |
Delay before the first redelivery (ms) | 1000 |
jms.redelivery.backoff-multiplier |
Multiplier applied to the delay per retry (only used when exponential backoff is enabled) | 5.0 |
jms.redelivery.exponential-backoff |
Enable exponential backoff between retries | false |
jms.client-id |
Base client ID for durable topic subscriptions. Required only if any @Listener sets durable = true. Each durable listener's connection uses <jms.client-id>-<subscriptionName>, so one value is safe to share across multiple durable listeners in the same service. |
— |
# Web
web.service.port=8080
web.service.scan-package=com.example
# Database (H2 in-memory)
db.url=jdbc:h2:mem:app;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
db.driver-class-name=org.h2.Driver
db.username=sa
db.password=
flyway.enabled=true
# JMS (ActiveMQ)
jms.broker.type=activemq
jms.broker.url=tcp://localhost:61616
jms.username=admin
jms.password=admin
jms.pool.max-connections=10
jms.pool.max-sessions-per-connection=500
jms.pool.idle-timeout=30000
jms.redelivery.max-redeliveries=6
jms.redelivery.initial-delay=1000
jms.redelivery.backoff-multiplier=5.0
jms.redelivery.exponential-backoff=false
jms.client-id=my-serviceProzed scans the classpath at startup, so how you package matters.
Use the Maven Shade plugin or Gradle Shadow to produce a single flat jar where all classes live at the root (app.jar!/com/example/...).
Each optional module ships a service-registration file at the same path:
META-INF/services/prozed.io.core.api.di.Bean. When you shade multiple modules into one jar, the build will overwrite these files unless you tell it to merge them — and if it does, JdbcOperations / JmsRegistry silently disappear and you'll get startup failures that don't reproduce in your IDE.
Maven Shade — full runnable-jar config:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.2</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<!-- don't emit dependency-reduced-pom.xml -->
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<!-- sets Main-Class so `java -jar` works -->
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.Main</mainClass>
</transformer>
<!-- merges META-INF/services/* instead of overwriting -->
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<!-- JPMS descriptors are meaningless in a flat uber-jar -->
<exclude>module-info.class</exclude>
<exclude>META-INF/versions/*/module-info.class</exclude>
<!-- drop signed-jar signatures, else SecurityException at startup -->
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>Without the
ManifestResourceTransformer, the jar has noMain-Classandjava -jarfails with "no main manifest attribute". Thefilterssilence the commonmodule-info.classshading warning and prevent a signed transitive dependency (e.g. via ActiveMQ) from triggeringSecurityException: Invalid signature file digestat startup. OverlappingMETA-INF/LICENSE/NOTICEwarnings are cosmetic — exclude them too if you want a clean build log.
Gradle Shadow:
shadowJar {
mergeServiceFiles() // does the same thing
}The spring-boot-maven-plugin repackage goal produces a nested layout (BOOT-INF/classes, BOOT-INF/lib/*.jar) that requires Spring Boot's custom class loader. Prozed's scanner does not read nested jars — package with Shade/Shadow instead.
Note: classpath scanning relies on the jar containing directory entries for your packages (standard Maven jars include them).
| Module | Description |
|---|---|
prozed-core |
DI container, routing, embedded Tomcat server, configuration, classpath scanning, scheduled tasks |
prozed-jdbc |
JdbcOperations, connection pooling, transactions, Flyway migrations |
prozed-jms |
JmsOperations, @Listener consumers (ActiveMQ) |
prozed-test |
@ProzedTest JUnit 5 extension + HttpClientOperations |
Runnable sample apps live under examples/:
| Example | Description |
|---|---|
simple-example |
A single-process app exercising every module (REST + JDBC + JMS + filters + tests) |
microservices-containerized-example |
A task tracker as three Prozed microservices + PHP UI, wired with JMS and Docker Compose |
The fastest way to learn Prozed is to read simple-example end to end.
git clone https://github.com/shaikezam/Prozed.git
cd Prozed
mvn clean installRun the simple-example app as a self-contained runnable jar:
# build the example (and the modules it depends on) into one fat jar
mvn -pl examples/simple-example -am clean package
# run it — no Maven needed at runtime
java -jar examples/simple-example/target/simple-example-1.0.0-SNAPSHOT.jarsimple-example configures the Shade plugin (see Packaging & Deployment), so package emits a runnable uber-jar with the module service files merged. For a multi-service, containerized setup, see microservices-containerized-example.
Requires JDK 21+ and Maven 3.9+.
Prozed is deliberately small. Current constraints worth knowing before you adopt it:
- One
ProzedServerper JVM — the DI container and port are process-global. - Field injection only (no constructor or setter injection), and injection is by concrete bean type (no interface-to-implementation binding).
- Flat / thin-jar packaging only — see Packaging & Deployment.
- JMS support targets ActiveMQ.
Found a rough edge? Open an issue — see below.
Contributions are very welcome — this is a small, approachable codebase, which makes it a great place to land your first framework PR.
- Fork the repo and create a branch:
git checkout -b feature/my-change - Make your change and add/adjust tests
- Run
mvn clean installand make sure everything is green - Open a Pull Request describing the change and the why
Bug reports and feature ideas are equally welcome via GitHub Issues. If you find Prozed useful, a ⭐ helps others discover it.
MIT © Shai Zambrovski