Separate your data access logic from your controllers with the Repository pattern. Extend BaseRepository<T> to get all common CRUD operations for free — no boilerplate required.
Everything inside the without block is already provided by BaseRepository — you don't need to write it.
@Repository
public class ArticleRepository {
public Article findById(int id) {
return Model.find(Article.class, id); // already in BaseRepository
}
public List<Article> findAll() {
return Model.all(Article.class); // already in BaseRepository
}
public boolean save(Article article) {
return article.save(); // already in BaseRepository
}
public boolean delete(int id) {
Article a = Model.find(Article.class, id);
if (a == null) return false;
return a.delete(); // already in BaseRepository
}
}
@Repository
public class ArticleRepository extends BaseRepository<Article> {
public ArticleRepository() {
super(Article.class);
}
// findById, findAll, save, delete, count, paginate... all inherited
// Only add what's specific to articles:
public List<Article> findPublished() {
return query().where("status", 1).orderByDesc("created_at").get();
}
public Optional<Article> findBySlug(String slug) {
return Optional.ofNullable(findBy("slug", slug));
}
}
| Method | Description |
|---|---|
| findAll() | All records |
| findById(id) | By primary key, or null |
| findByIdOrFail(id) | By primary key, throws ModelNotFoundException |
| findBy(column, value) | First match on a column |
| findAllBy(column, value) | All matches on a column |
| findMany(ids) | Multiple records by primary key list |
| findByAttributes(map) | First match on multiple columns (AND) |
| create(attributes) | Insert and return new record |
| firstOrCreate(search, extra) | Find or create |
| updateOrCreate(search, attributes) | Find and update, or create if not found |
| update(id, attributes) | Update by primary key |
| updateWhere(column, value, attributes) | Bulk update by condition |
| delete(id) | Delete by primary key |
| destroy(ids...) | Delete multiple by primary key |
| deleteWhere(column, value) | Bulk delete by condition |
| count() | Total row count |
| countWhere(column, value) | Count matching a condition |
| exists(id) | Check existence by primary key |
| existsWhere(column, value) | Check existence by condition |
| paginate(page, perPage) | Paginated results (default 15 per page) |
| latest() | All records ordered by created_at DESC |
| latest(limit) | Last N records |
| oldest() | All records ordered by created_at ASC |
| first() | First record |
| pluck(column) | Extract a single column as a list |
| pluckWhere(column, whereCol, whereVal) | Pluck with a condition |
| max(column) | Maximum value of a column |
| min(column) | Minimum value of a column |
| sum(column) | Sum of a column |
| avg(column) | Average of a column |
| chunk(size, callback) | Iterate all records in batches without loading the full table |
| chunkWhere(column, value, size, callback) | Same as chunk with a WHERE filter |
| upsert(rows, uniqueKeys) | Insert or update on conflict — updates all non-unique columns |
| upsert(rows, uniqueKeys, updateKeys) | Insert or update on conflict — updates only specified columns |
Use query() to start a builder scoped to your model for anything that goes beyond the built-ins:
@Repository
public class ArticleRepository extends BaseRepository<Article> {
public ArticleRepository() { super(Article.class); }
public Paginator<Article> paginatePublished(int page) {
return query()
.where("status", 1)
.orderByDesc("created_at")
.paginate(page, 15);
}
public List<Article> findByAuthorWithComments(int userId) {
return query()
.where("user_id", userId)
.with("comments", "author")
.get();
}
public List<Article> findRecentByCategory(int categoryId, int limit) {
return query()
.where("category_id", categoryId)
.where("status", 1)
.orderByDesc("created_at")
.limit(limit)
.get();
}
}
Find the first record matching search and update it with attributes, or create a new one merging both if none is found.
// Updates Alice's role if found, creates her if not
User user = userRepo.updateOrCreate(
Map.of("email", "alice@example.com"),
Map.of("role", "admin", "active", 1)
);
Process large tables without loading everything into memory. The callback receives one batch at a time.
// Process all users 500 at a time
userRepo.chunk(500, users -> {
users.forEach(u -> u.set("active", 1).save());
});
// Same with a WHERE filter
userRepo.chunkWhere("role", "user", 200, users -> {
users.forEach(u -> notificationService.send(u));
});
Uses offset-based pagination internally. Avoid inserting or deleting rows in the same table during iteration — pages may shift.
Insert multiple rows, updating on conflict. Uses dialect-specific syntax — ON DUPLICATE KEY UPDATE on MySQL, ON CONFLICT DO UPDATE on PostgreSQL and SQLite.
List<Map<String, Object>> rows = List.of(
Map.of("email", "alice@example.com", "name", "Alice", "role", "admin"),
Map.of("email", "bob@example.com", "name", "Bob", "role", "user")
);
// Conflict on email — updates all non-unique columns on conflict
userRepo.upsert(rows, List.of("email"));
// Only update the role column on conflict
userRepo.upsert(rows, List.of("email"), List.of("role"));
The uniqueKeys column must have a UNIQUE or PRIMARY KEY constraint in the database schema. SQLite requires version 3.24+.
@Controller("/articles")
public class ArticleController extends BaseController {
@GET("")
private Object index(ArticleRepository articles, Request req) {
int page = Integer.parseInt(req.queryParamOrDefault("page", "1"));
return render("articles/index.html", Map.of(
"articles", articles.paginatePublished(page)
));
}
@GET("/:id")
private Object show(ArticleRepository articles, Request req) {
Article article = articles.findByIdOrFail(req.params("id"));
return render("articles/show.html", Map.of("article", article));
}
}
Always extend BaseRepository. Only add methods that are specific to your domain — everything else is already there. Any class annotated with @Repository is automatically available for injection via @Inject or as a controller method parameter.