Build a real CRUD with relationships
In this tutorial, you’ll build a more realistic Mateu backoffice screen.
By the end, you’ll have:
- a CRUD screen
- validation
- foreign keys
- dynamic option loading
- user feedback
- a structure that fits well with Spring Boot and hexagonal / DDD-style applications
1. What we are building
We’ll model a simple Role screen.
A role has:
- an id
- a name
- a description
- a list of permissions
The UI will let you:
- list roles
- create a role
- edit a role
- delete roles
- select permissions dynamically
2. Create the view model
Start with the form model.
package com.example.demo.ui.roles;
import io.mateu.uidl.annotations.*;
import io.mateu.uidl.data.FieldStereotype;
import io.mateu.uidl.data.Message;
import io.mateu.uidl.interfaces.CrudCreationForm;
import io.mateu.uidl.interfaces.CrudEditorForm;
import io.mateu.uidl.interfaces.HttpRequest;
import io.mateu.uidl.interfaces.Identifiable;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
public class RoleViewModel implements Identifiable, CrudEditorForm<String>, CrudCreationForm<String> {
@EditableOnlyWhenCreating
@NotEmpty
String id;
@NotEmpty
String name;
@Colspan(2)
@Style("width: 100%;")
String description;
@ForeignKey(search = PermissionIdOptionsSupplier.class, label = PermissionIdLabelSupplier.class)
@Colspan(2)
@Style("width: 100%;")
@Stereotype(FieldStereotype.checkbox)
List<String> permissions;
@Override
public String create(HttpRequest httpRequest) {
return id;
}
@Override
public void save(HttpRequest httpRequest) {
}
@Override
public String id() {
return id;
}
}
This already gives you several important things:
- state through fields
- validation through Bean Validation
- layout hints through
@Colspanand@Style - relationships through
@ForeignKey - rendering intent through
@Stereotype
3. Add creation and save logic
Now connect the form to your application layer.
public class RoleViewModel implements Identifiable, CrudEditorForm<String>, CrudCreationForm<String> {
@EditableOnlyWhenCreating
@NotEmpty
String id;
@NotEmpty
String name;
@Colspan(2)
@Style("width: 100%;")
String description;
@ForeignKey(search = PermissionIdOptionsSupplier.class, label = PermissionIdLabelSupplier.class)
@Colspan(2)
@Style("width: 100%;")
@Stereotype(FieldStereotype.checkbox)
List<String> permissions;
final CreateRoleUseCase createRoleUseCase;
final SaveRoleUseCase saveRoleUseCase;
public RoleViewModel(CreateRoleUseCase createRoleUseCase, SaveRoleUseCase saveRoleUseCase) {
this.createRoleUseCase = createRoleUseCase;
this.saveRoleUseCase = saveRoleUseCase;
}
@Override
public String create(HttpRequest httpRequest) {
createRoleUseCase.handle(new CreateRoleCommand(id, name, description, permissions));
return id;
}
@Override
public void save(HttpRequest httpRequest) {
saveRoleUseCase.handle(new SaveRoleCommand(id, name, description, permissions));
}
@Override
public String id() {
return id;
}
}
This is where Mateu fits nicely with Spring Boot and application services:
- Mateu handles UI rendering and interaction
- your use cases handle business behavior
4. Load data into the form
When editing an existing role, you need to load it.
public RoleViewModel load(RoleDto role) {
id = role.id();
name = role.name();
description = role.description();
permissions = role.permissionIds();
return this;
}
This keeps the UI model simple and explicit.
5. Build the CRUD adapter
The adapter connects Mateu’s CRUD lifecycle to your application/query layer.
package com.example.demo.ui.roles;
import io.mateu.uidl.data.ListingData;
import io.mateu.uidl.data.NoFilters;
import io.mateu.uidl.data.Pageable;
import io.mateu.uidl.interfaces.CrudAdapter;
import io.mateu.uidl.interfaces.HttpRequest;
import java.util.List;
public class RoleCrudAdapter implements CrudAdapter<
RoleViewModel,
RoleViewModel,
RoleViewModel,
NoFilters,
RoleRow,
String> {
final RoleViewModel viewModel;
final RoleQueryService queryService;
final DeleteRoleUseCase deleteRoleUseCase;
public RoleCrudAdapter(
RoleViewModel viewModel,
RoleQueryService queryService,
DeleteRoleUseCase deleteRoleUseCase) {
this.viewModel = viewModel;
this.queryService = queryService;
this.deleteRoleUseCase = deleteRoleUseCase;
}
@Override
public ListingData<RoleRow> search(String searchText, NoFilters filters, Pageable pageable) {
return queryService.findAll(searchText, filters, pageable);
}
@Override
public void deleteAllById(List<String> selectedIds) {
deleteRoleUseCase.handle(new DeleteRoleCommand(selectedIds));
}
@Override
public RoleViewModel getView(String id) {
return viewModel.load(queryService.getById(id).orElseThrow());
}
@Override
public RoleViewModel getEditor(String id) {
return viewModel.load(queryService.getById(id).orElseThrow());
}
@Override
public RoleViewModel getCreationForm(HttpRequest httpRequest) {
return viewModel;
}
}
This is a very good separation of concerns:
- queries for reads
- use cases for writes
- Mateu adapter for CRUD lifecycle
6. Expose the CRUD through an orchestrator
Now wrap everything in a CrudOrchestrator.
package com.example.demo.ui.roles;
import io.mateu.core.infra.declarative.CrudOrchestrator;
import io.mateu.uidl.annotations.Title;
import io.mateu.uidl.data.NoFilters;
import io.mateu.uidl.interfaces.CrudAdapter;
@Title("Roles")
public class RolesCrudOrchestrator extends CrudOrchestrator<
RoleViewModel,
RoleViewModel,
RoleViewModel,
NoFilters,
RoleRow,
String> {
final RoleCrudAdapter adapter;
public RolesCrudOrchestrator(RoleCrudAdapter adapter) {
this.adapter = adapter;
}
@Override
public CrudAdapter<RoleViewModel, RoleViewModel, RoleViewModel, NoFilters, RoleRow, String> adapter() {
return adapter;
}
@Override
public String toId(String s) {
return s;
}
}
At this point, you already have a real CRUD screen.
7. Add the foreign key suppliers
Now wire the permissions relationship properly.
Options supplier
package com.example.demo.ui.roles;
import io.mateu.uidl.data.ListingData;
import io.mateu.uidl.data.Option;
import io.mateu.uidl.data.Page;
import io.mateu.uidl.data.Pageable;
import io.mateu.uidl.interfaces.ForeignKeyOptionsSupplier;
import io.mateu.uidl.interfaces.HttpRequest;
public class PermissionIdOptionsSupplier implements ForeignKeyOptionsSupplier {
final PermissionQueryService queryService;
public PermissionIdOptionsSupplier(PermissionQueryService queryService) {
this.queryService = queryService;
}
@Override
public ListingData<Option> search(String searchText, Pageable pageable, HttpRequest httpRequest) {
var found = queryService.findAll(searchText, null, pageable);
return new ListingData<>(new Page<>(
searchText,
found.page().pageSize(),
found.page().pageNumber(),
found.page().totalElements(),
found.page().content().stream()
.map(permission -> new Option(permission.id(), permission.name()))
.toList()
));
}
}
Label supplier
package com.example.demo.ui.roles;
import io.mateu.uidl.interfaces.HttpRequest;
import io.mateu.uidl.interfaces.LabelSupplier;
public class PermissionIdLabelSupplier implements LabelSupplier {
final PermissionQueryService queryService;
public PermissionIdLabelSupplier(PermissionQueryService queryService) {
this.queryService = queryService;
}
@Override
public String label(Object id, HttpRequest httpRequest) {
return queryService.getLabel((String) id);
}
}
Now the permissions field is not just a list — it is a real backend-resolved relationship.
8. Add the CRUD to a menu
Now expose the CRUD in the UI.
package com.example.demo.ui;
import io.mateu.uidl.annotations.Menu;
import io.mateu.uidl.annotations.Title;
import io.mateu.uidl.annotations.UI;
import com.example.demo.ui.roles.RolesCrudOrchestrator;
@UI("/admin")
@Title("Admin")
public class AdminHome {
@Menu
AdminMenu admin;
}
package com.example.demo.ui;
import io.mateu.uidl.annotations.Menu;
import com.example.demo.ui.roles.RolesCrudOrchestrator;
public class AdminMenu {
@Menu
RolesCrudOrchestrator roles;
}
That gives you:
- a UI root
- a menu entry
- navigation into the CRUD
9. Add user feedback
You can return browser feedback directly from backend actions.
For example, if you expose a custom save action:
@Button
public Message notifySave() {
return new Message("Role saved successfully");
}
Mateu will display the notification in the browser without any frontend code.
10. What this tutorial introduced
In one CRUD, you used:
@UI@MenuCrudOrchestratorCrudAdapter- Bean Validation
@ForeignKey- options supplier
- label supplier
@StereotypeMessage
That already covers most of what a real backoffice needs.
11. Why this structure works well
This pattern fits very well with Spring Boot and layered architectures:
- Mateu defines and renders the UI
- query services provide read models
- use cases handle writes
- repositories stay behind the application layer
So you can build a real backoffice without collapsing your architecture into UI code.
12. Next step
👉 Continue with: