Minimalist Java Framework for Developers who value Control

In today’s world of Java development, frameworks like SpringBoot, Quarkus, Play, and Micronaut offer convenience - but at a cost.
With these frameworks, developers often find themselves overwhelmed by hidden conventions, unnecessary layers of complexity, and an over-reliance on "magic" to get things working.

Once you need to optimize and debug, the once-simple setup becomes a tangled mess that’s hard to understand and even harder to improve. It’s 20 minutes to implement 80% of functionality but 80 hours to debug and optimize the necessary 20%.

Just like our use of Comic Sans for this paragraph, we’re here to break conventions.

Opiniated, magicky frameworks, with lots of hidden conventions are making developers dumb.

Interviewers from all around the world

Designed to put power back into your hands

Small and lightweight

Less than 100 files, totalling less than 2000 lines of code, less than 500Kb in total size.

Easy to learn and develop with

No unnecessary layers of abstraction.

No reinvention of the wheel

Uses standard Java servlets, filters, SQL. No new conventions to learn. No mismatched paradigms to wrestle (ORM much?).

VERBOSE and fully in control

No mysterious conventions, no hidden "magic", you are in charge, not the framework. Be confident in your code, not "seems to be working if I do this". Know exactly where to debug and optimize, because you wrote the code, not hidden inside the framework.

Key concepts
Transaction wrapping
Everything inside TransactionContext is 1 database transaction, either all are committed or rolled back.
This lets you combine/pick/mix Service methods inside your Controller and they will be executed in 1 database transaction.
The Service methods can now be basic read/write from/to database and business logic can stay in the Controllers.
Throw a RuntimeException anywhere inside the code block to rollback.
@Path(value = "/order/checkout", allow = {}, deny = {})
public View checkout(@Parameter("person") Cart cart) {
	Integer setting = 1000;
	return transactionService.action(new TransactionContext<View>() {
		public View action(Object... array) {
			Cart cart = (Cart) array[0];
			Integer setting = (Integer) array[1];
	
			// for each product in cart
			// if there is enough inventory to fullfil order then reduce inventory else throw a RuntimeException

			// if user's wallet has enough wallet, then deduct from wallet's balance, else throw a RuntimeException

			return ok;
		}
	}, cart, setting);
}
				
There are 4 method signatures:
  • Takes no parameters and doesn't return
  • Takes parameters and doesn't return
  • Takes no parameters and returns an object
  • Takes parameters and returns an object
  • Note: if you don't like to pass parameters you can wrap your non-final objects in some sort of Container or a Map.
    @Singleton
    public class TransactionService {
    
    	@Transactional
    	public void execute(TransactionContext transactionContext) {
    		transactionContext.execute();
    	}
    
    	@Transactional
    	public void execute(TransactionContext transactionContext, Object... array) {
    		transactionContext.execute(array);
    	}
    
    	@Transactional
    	public <T> T action(TransactionContext<T> transactionContext) {
    		return transactionContext.action();
    	}
    
    	@Transactional
    	public <T> T action(TransactionContext<T> transactionContext, Object... array) {
    		return transactionContext.action(array);
    	}
    
    }
    				
    SQL & Entity
    An Entity is a POJO, just define the getters and setters, it does not need a special annotation.
    Leverage all the power of SQL, since everything is just plain SQL. No new language to learn.
    @Transactional
    public List<Person> list(Boolean active) {
    	// tri-state, if active is null it returns all
    	return (list(Person.class, "select " + 
    		"id, storageMapData, name, email, password, active, image, birthDate, roleSetData, attachmentListData " + 
    		"from person " +
    		"where (? is null or active = ?) order by id", 
    		active, active);
    }
    
    @Transactional
    public Material findById(Long id, boolean calculateQuantity) {
    	// since the SQL is a regular String (as opposed to an annotation), you can do conditional statements and any other String operations
    	return fromDecorator.decorate(find(Material.class,
    			"select " +
    					"material.*" + (calculateQuantity ? ", coalesce(data.quantity, 0) as quantity" : "") +
    					"from material " +
    					(calculateQuantity ? "left join (select materialId, sum(movement) as quantity from materialMovement group by materialId) data on material.id = data.materialId " : "") +
    					"where material.id = ?",
    			id));
    }
    
    @Transactional
    public List<Person> list(Integer offset, Integer limit, String sortField, String sortDirection) {
    	// guard against SQL injection
    	List<String> fieldList = Arrays.asList("id", "name", "email", "active", "role");
    	if (fieldList.contains(sortField) && ("asc".equals(sortDirection) || "desc".equals(sortDirection))) {
    	} else {
    		sortField = "id";
    		sortDirection = "desc";
    	}
    	return fromDecorator.decorate(list(Person.class, "select " +
    			"id, storageMapData, name, email, password, active, image, birthDate, roleSetData, attachmentListData " +
    			"from person " +
    			"order by " + sortField + " " + sortDirection + " limit ? offset ?",
    			limit, offset));
    }
    				
    When the framework sees a column called "name", it will call setName() on the entity object (which is just a POJO).
    SQL Piggybacking
    Specify what field to set using JavaBeans rule.
    e.g.
    name -> setName()
    person.address.value -> getPerson().getAddress().setValue()
    person.address[1].value -> getPerson().getAddress().get(1).setValue()
    person.address("main").value -> getPerson().getAddress().get("main").setValue()
    This lets you piggy back some data onto the entity object (e.g. brand name of a product, inventory quantity of a material, shop's owner's name, etc)
    For the exact rule see https://commons.apache.org/proper/commons-beanutils/ especially the PropertyUtilsBean documentation https://commons.apache.org/proper/commons-beanutils/javadocs/v1.9.4/apidocs/org/apache/commons/beanutils/PropertyUtilsBean.html
    Because map.level.name, depending on the types or the objects, it could mean getMap().getLevel().getName() or getMap().get("level").get("name")
    // if this is your entity object
    public class Product extends BaseModel {
    	protected String name;
    	protected String brandName;
    
    	// getters and setters
    }
    
    @Transactional
    public List<Product> list() {
    	// since brand.name has a name/alias of "brandName", the framework will call product.setBrandName()
    	return list(Product.class, "select product.*, brand.name as `brandName` from product left join brand on brand.id = product.brandId order by product.name");
    }
    
    // if your base model (where it is inherited by each Model) looks like this (i.e. it has a Map)
    public class BaseModel {
    	protected Long id;
    	protected Map<String, Object> map = new HashMap<>();
    
    	public Object get(String key) {
    		if (map != null) {
    			return map.get(key);
    		}
    		return null;
    	}
    
    	public Object set(String key, Object value) {
    		if (map != null) {
    			return map.put(key, value);
    		}
    		return null;
    	}
    }
    
    // then you don't need to even create a special field like brandName, just map it to the Map
    @Transactional
    public List<Product> list() {
    	// since brand.name has a name/alias of "map.brandName", the framework will call product.getMap().put("brandName", value)
    	return list(Product.class, "select product.*, brand.name as `map.brandName` from product left join brand on brand.id = product.brandId order by product.name");
    }
    
    // another example
    @Transactional
    public List<Shop> listAgain() {
    	// framework will call shop.setId(), shop.setName()
    	// shop.getMap().put("whatever", value), shop.getAdditionalProductList().get(2).setName()
    	// shop.getMap().get("level1").get("level2").get("level3").get("level4").get("level5").put("name", value)
    	return list(Shop.class, "select id, name, slug as `map.whatever`, name as `additionalProductList[2].name`, 
    			name as `map.level1.level2.level3.level4.level5.name` from shop order by name");
    }
    				
    List of Multiple Objects
    Instead of returning a list of single object type e.g. list of products, you can return a list of multiple object types, e.g. list of products and brands.
    // return as object array
    @Transactional
    public List<Object[]> listMultiple() {
    	List<Object[]> list = list(Builder.build(PersistenceModule.classTypeMapping, null, new Specification[] { //
    		new Specification("first", Brand.class), //
    		new Specification("second", Product.class) //
    		}), "select " + //
    			"brand.id as firstId, brand.name as firstName, brand.slug as firstSlug, " + //
    			"product.id as secondId, product.name as secondName, product.slug as secondSlug " + //
    			"from product join brand on product.brandId = brand.id order by brand.name");
    	return list;
    }
    				
    BYOB - Bring Your Own Builder
    Have complete control on how you build your entity object by specifying your own Builder.
    @Transactional
    public List<Shop> listBuild() {
    	return apply(list(new Builder<Shop>() {
    		public Shop build(ResultData rd) throws SQLException {
    			Shop shop = new Shop();
    			shop.setId(rd.getLong("shop.id"));
    			shop.setSlug(rd.getString("shop.slug"));
    			shop.setName(rd.getString("shop.name"));
    			shop.set("whateverName", rd.getString("product.name"));
    			shop.set("whateverSlug", rd.getString("product.Slug"));
    			shop.set("whateverQuantity", rd.getString("product.Quantity"));
    			return shop;
    		}
    	}, "select shop.id, product.slug as name from shop join product on product.shopId = shop.id order by shop.name"), sanitizer);
    }
    				
    Native Support of Master-Slave Style Multiple Datasources
    Annotate each Service write methods with a value e.g. "Master" and each read methods with another value e.g. "Slave".
    Then, each time a Master method is executed it will use the Master datasource (connected to your Master database).
    While Slave methods will use the Slave datasource.
    @Transactional(type = "Master")
    public int save(Shop shop) {
    	if (shop.getId() != null) {
    		return super.update("shop", shop, new String[] { "id" }, "slug", "name");
    	} else {
    		return super.create("shop", shop, "id", "slug", "name");
    	}
    }
    
    @Transactional(type = "Slave")
    public Shop find(Long id) {
    	return apply(find(Shop.class, "select * from shop where id = ?", id), sanitizer);
    }
    				
    Decorator
    Execute code before an object is persisted to database and after it is loaded from database.
    With this you can use JSON formatted arrays or maps (avoiding the n+1 problem).
    e.g.
    String[] favouriteMovieList;
    String favouriteMovieListData; // {"Top Gun", "Rocky", "Billy Madison"}
    Before you persist, convert to JSON from favouriteMovieList to favouriteMoveListData
    After you load, convert from JSON from favouriteMovieListData to favouriteMovieList
    Note though only you can only do this if your data is not referenced by another data.

    You can also use this to format dates for example.
    public static final Decorator<Person> toDecorator = new Decorator<Person>() {
    	public Person decorate(Person entity) {
    		if (entity == null) {
    			return entity;
    		}
    		toDecorate(entity);
    
    		if (entity.getRoleSet() != null) {
    			entity.setRoleSetData(Utility.gson.toJson(entity.getRoleSet()));
    		}
    		return entity;
    	}
    };
    
    public static final Decorator<Person> fromDecorator = new Decorator<Person>() {
    	public Person decorate(Person entity) {
    		if (entity == null) {
    			return entity;
    		}
    		fromDecorate(entity);
    
    		entity.set("birthDate", Utility.format(Utility.dateFormat, entity.getBirthDate()));
    		entity.set("birthDateFull", Utility.format(Utility.fullDateFormat, entity.getBirthDate()));
    
    		if (Utility.isNotBlank(entity.getRoleSetData())) {
    			entity.setRoleSet(Utility.gson.fromJson(entity.getRoleSetData(), Utility.typeSetOfPersonRole));
    			entity.setRoleSetData(null);
    		}
    		return entity;
    	}
    };
    				
    Sanitizer
    Use a Sanitizer to remove data before being sent back to the client/frontend either because it is sensitive or reduce payload e.g. a password hash.
    A Sanitizer implements the Function interface.
    This way it can be chained. e.g. apply(sanitizerNoPassword, sanitizerNoSalary, sanitizerNoPersonalInformation).
    It works on a single instance or a list of instances.
    public static Function<Person, Person> sanitizerNoPassword = new Function<Person, Person>() {
    	public Person apply(Person entity) {
    		if (entity != null) {
    			entity.setPassword(null);
    		}
    		return entity;
    	}
    };
    
    @Path(value = "/system/person", allow = { "Administrator", "Tenant" }, deny = {})
    public View find(@Parameter("id") Long id) {
    	Person person = personService.apply(personService.findById(id), personService.sanitizer, personService.sanitizerNoPassword);
    	return person != null ? ok(person) : notFound;
    }
    				
    Native REST JSON
    Data can be specified via query string, post data or via body using the Parameter annotation in JSON format.
    Using a path variable is also possible.
    Path can be a regular expression.
    // via query string/post data would be person.name=Bob&person.gender=Male
    @Path(value = "/system/person/add", allow = { "Administrator", "Tenant" }, deny = {})
    public View add(@Parameter("person") Person person) {
    	return ok;
    }
    
    // via request body would be {"person":{"name":"Bob","gender":"Male"}}
    @Path(value = "/system/person/edit", allow = { "Administrator", "Tenant" }, deny = {})
    public View add(@Parameter("person") Person person) {
    	return ok;
    }
    
    // path regex
    // path variable
    // a method handling multiple paths
    @PathSet({ //
    	@Path(value = "/system/common/shop/([0-9]+)", name = "entityId", allow = {}, deny = {}), //
    	@Path(value = "/system/common/(store|shop)/find/([0-9]+)", name = "qualifier,entityId", method = "POST", allow = {}, deny = {}) //
    })
    public View find( //
    	@Parameter("productId") Long productId, //
    	@Parameter("queryObject") Object queryObject, //
    	@Parameter("entityId") Long entityId, //
    	@Parameter("qualifier") String qualifier, //
    	@Parameter("genericMap") Map genericMap, //
    	@Parameter("shop") Shop shop, //
    	@Parameter("shopList") List<Shop> shopList, //
    	@Parameter("object") Object object //
    ) {
    	return ok;
    }
    				
    Batch Support
    Native SQL batching support.
    @Transactional
    public void batchInsert(List<Shop> entityList) {
    	String sql = "insert into shop (slug, name) values (?, ?)";
    	batch(50, sql, toDecorator, new BatchPreparer<Shop>() {
    		public Object[] toFieldArray(Shop entity) {
    			return new Object[] { 
    					entity.getSlug(), 
    					entity.getName() 
    			};
    		}
    	}, entityList);
    }
    
    				
    And much more...
    Native support for file upload, transaction isolation levels, validators, method level access/deny list, etc.
    Repositories
    We have 5 repositories:
    A collection of common utility classes and functions used by the other frameworks.
    Orion is the web framework.
    Omega is the database framework.
    Epsilon is the base framework that glues common, orion and omega together.
    Theta is our example project to showcase Epsilon's capabilities.