Golden example: Orders, Customers and Order lines
This example shows how to build a realistic business UI with Mateu.
It combines:
- a main CRUD (
Orders) - a lightweight relationship (
Customer) - a child CRUD (
OrderLines) - validation
- backend services / repositories
- master-detail composition
The goal is to show the preferred Mateu way to model a real business screen.
What we are building
Section titled “What we are building”We want an order management UI with:
- a list of orders
- create / edit / view order screens
- a customer selector
- order lines embedded inside the order detail
- CRUD behavior for order lines
Mental model
Section titled “Mental model”Use:
@Lookupfor lightweight references- scalar ids for relationships
- embedded orchestrators for child collections
Callable<?>to compose dynamic child UI after hydration
Do not model child collections as List<Entity> when you want full CRUD behavior.
1. Order CRUD
Section titled “1. Order CRUD”The main entry point is a standard CRUD UI.
@Service@UI("/orders")public class Orders extends AutoCrudOrchestrator<Order> {
final OrderAdapter adapter;
public Orders(OrderAdapter adapter) { this.adapter = adapter; }
@Override public AutoCrudAdapter<Order> simpleAdapter() { return adapter; }}With this, Mateu provides the standard CRUD flow:
/orders→ list/orders/:id→ readonly detail/orders/:id/edit→ edit/orders/new→ create
2. Order model
Section titled “2. Order model”The order stores a customer id, not a Customer entity.
public record Order(
@NotEmpty @EditableOnlyWhenCreating String id,
@NotEmpty @Lookup( search = CustomerOptionsSupplier.class, label = CustomerLabelSupplier.class ) String customerId,
@NotNull LocalDate orderDate,
@NotNull @Stereotype(FieldStereotype.radio) OrderStatus status
) implements Identifiable {
@Override public String toString() { return id != null ? "Order " + id : "New order"; }}public enum OrderStatus { Draft, Confirmed, Shipped, Cancelled}Why customerId and not Customer
Section titled “Why customerId and not Customer”This is intentional.
@Lookup(search = CustomerOptionsSupplier.class, label = CustomerLabelSupplier.class)String customerId;This keeps the UI decoupled from the domain model.
Mateu only needs:
- the stored value (
customerId) - a way to search customers
- a way to render labels
3. Customer lookup
Section titled “3. Customer lookup”The lookup has two parts:
LookupOptionsSupplier→ searches available customersLabelSupplier→ resolves selected customer labels
Customer options
Section titled “Customer options”@Servicepublic class CustomerOptionsSupplier implements LookupOptionsSupplier {
final CustomerRepository repository;
public CustomerOptionsSupplier(CustomerRepository repository) { this.repository = repository; }
@Override public ListingData<Option> search( String fieldName, String searchText, Pageable pageable, HttpRequest httpRequest) {
return ListingData.of( repository.findAll(searchText, pageable).stream() .map(customer -> new Option(customer.id(), customer.name())) .toList() ); }}Customer labels
Section titled “Customer labels”@Servicepublic class CustomerLabelSupplier implements LabelSupplier {
final CustomerRepository repository;
public CustomerLabelSupplier(CustomerRepository repository) { this.repository = repository; }
@Override public String label(String fieldName, Object id, HttpRequest httpRequest) { return repository.findById(String.valueOf(id)) .map(Customer::name) .orElse("?"); }}4. Order adapter and repository
Section titled “4. Order adapter and repository”@Servicepublic class OrderAdapter extends AutoCrudAdapter<Order> {
final OrderRepository repository;
public OrderAdapter(OrderRepository repository) { this.repository = repository; }
@Override public CrudRepository<Order> repository() { return repository; }}@Servicepublic class OrderRepository implements CrudRepository<Order> {
private final Map<String, Order> db = new LinkedHashMap<>();
@Override public Optional<Order> findById(String id) { return Optional.ofNullable(db.get(id)); }
@Override public String save(Order entity) { db.put(entity.id(), entity); return entity.id(); }
@Override public List<Order> findAll() { return db.values().stream().toList(); }
@Override public void deleteAllById(List<String> selectedIds) { selectedIds.forEach(db::remove); }}5. Adding order lines
Section titled “5. Adding order lines”Order lines are child records.
They have their own lifecycle:
- list
- create
- edit
- delete
So we do not put them in the order as:
List<OrderLine> lines;That would be inferred as an editable structure, not as a child CRUD.
Instead, we embed a child orchestrator.
6. Order detail with embedded lines
Section titled “6. Order detail with embedded lines”If you want to add custom content to the order detail, compose it as dynamic UI.
@Service@Route("/orders/:id")@Style(StyleConstants.CONTAINER)public class OrderDetail {
final OrderRepository orderRepository;
String id;
@ReadOnly String customerId;
@ReadOnly LocalDate orderDate;
@ReadOnly OrderStatus status;
public OrderDetail(OrderRepository orderRepository) { this.orderRepository = orderRepository; }
public void onHydrated(HttpRequest httpRequest) { orderRepository.findById(id).ifPresent(order -> { customerId = order.customerId(); orderDate = order.orderDate(); status = order.status(); }); }
Callable<?> lines = () -> MateuBeanProvider .getBean(OrderLines.class) .withOrderId(id);}Depending on your application structure, you may rely on the default detail generated by
AutoCrudOrchestrator, or provide an explicit route when you need custom composition.
7. Order line model
Section titled “7. Order line model”public record OrderLine(
@NotEmpty @EditableOnlyWhenCreating String id,
@NotEmpty @HiddenInList String orderId,
@NotEmpty String productName,
@NotNull Integer quantity,
@NotNull BigDecimal unitPrice
) implements Identifiable {
@Override public String toString() { return productName != null ? productName : "New line"; }}8. Embedded child CRUD
Section titled “8. Embedded child CRUD”@Servicepublic class OrderLines extends AutoListOrchestrator<OrderLine> {
final OrderLineAdapter adapter;
String orderId;
public OrderLines(OrderLineAdapter adapter) { this.adapter = adapter; }
public OrderLines withOrderId(String orderId) { this.orderId = orderId; adapter.withOrderId(orderId); return this; }
@Override public AutoCrudAdapter<OrderLine> simpleAdapter() { return adapter; }}9. Filtering the child CRUD by parent id
Section titled “9. Filtering the child CRUD by parent id”The important part is that the child adapter receives the parent context.
@Servicepublic class OrderLineAdapter extends AutoCrudAdapter<OrderLine> {
final OrderLineRepository repository;
String orderId;
public OrderLineAdapter(OrderLineRepository repository) { this.repository = repository; }
public OrderLineAdapter withOrderId(String orderId) { this.orderId = orderId; return this; }
@Override public CrudRepository<OrderLine> repository() { return new CrudRepository<>() {
@Override public Optional<OrderLine> findById(String id) { return repository.findById(id); }
@Override public String save(OrderLine entity) { return repository.save(new OrderLine( entity.id(), orderId, entity.productName(), entity.quantity(), entity.unitPrice() )); }
@Override public List<OrderLine> findAll() { return repository.findByOrderId(orderId); }
@Override public void deleteAllById(List<String> selectedIds) { repository.deleteAllById(selectedIds); } }; }}Why this matters
Section titled “Why this matters”The child CRUD is scoped by the parent order id.
That means:
- order lines are listed only for the current order
- new lines are automatically assigned to the current order
- the child UI stays independent
- the domain model does not leak into the parent view model
10. Order line repository
Section titled “10. Order line repository”@Servicepublic class OrderLineRepository {
private final Map<String, OrderLine> db = new LinkedHashMap<>();
public Optional<OrderLine> findById(String id) { return Optional.ofNullable(db.get(id)); }
public String save(OrderLine entity) { db.put(entity.id(), entity); return entity.id(); }
public List<OrderLine> findByOrderId(String orderId) { return db.values().stream() .filter(line -> Objects.equals(orderId, line.orderId())) .toList(); }
public void deleteAllById(List<String> ids) { ids.forEach(db::remove); }}What this example demonstrates
Section titled “What this example demonstrates”This example shows several important Mateu principles:
1. Prefer model-driven UI
Section titled “1. Prefer model-driven UI”The main CRUD is defined from the Order model.
2. Use lookups for references
Section titled “2. Use lookups for references”Customer is represented as a scalar id with search and label suppliers.
3. Use embedded CRUDs for child collections
Section titled “3. Use embedded CRUDs for child collections”Order lines have their own lifecycle, so they are modeled as a child CRUD.
4. Use Callable<?> for dynamic composition
Section titled “4. Use Callable<?> for dynamic composition”The child CRUD needs the hydrated orderId, so it is created lazily.
5. Keep boundaries explicit
Section titled “5. Keep boundaries explicit”Nothing is loaded implicitly through List<Entity>.
The parent decides what child UI to embed.
Common mistakes
Section titled “Common mistakes”Mistake 1: using Customer customer
Section titled “Mistake 1: using Customer customer”Prefer:
String customerId;with:
@Lookup(search = CustomerOptionsSupplier.class, label = CustomerLabelSupplier.class)Mistake 2: using List<OrderLine>
Section titled “Mistake 2: using List<OrderLine>”Prefer:
Callable<?> lines = () -> MateuBeanProvider .getBean(OrderLines.class) .withOrderId(id);Mistake 3: creating pages too early
Section titled “Mistake 3: creating pages too early”For standard CRUD, start with the model and orchestrator.
Create explicit pages only when you need custom composition or custom flows.
Summary
Section titled “Summary”The Mateu way for this example is:
Orders extends AutoCrudOrchestrator<Order>Order.customerIduses@LookupOrderLines extends AutoListOrchestrator<OrderLine>- parent detail embeds child CRUD with
Callable<?> - child CRUD is filtered by parent context
This gives you a real business UI without creating a separate frontend application.
- Full control with CrudOrchestrator — when you need separate models for filters, rows, views, and forms
- Customizing CRUD and listings — annotation-driven refinements to the default CRUD behavior
- Admin panel example — a full application combining several CRUDs into a single backoffice shell