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
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
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
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
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
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
The lookup has two parts:
LookupOptionsSupplier→ searches available customersLabelSupplier→ resolves selected customer labels
Customer options
@Service
public 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
@Service
public 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
@Service
public class OrderAdapter extends AutoCrudAdapter<Order> {
final OrderRepository repository;
public OrderAdapter(OrderRepository repository) {
this.repository = repository;
}
@Override
public CrudRepository<Order> repository() {
return repository;
}
}
@Service
public 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
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
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
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
@Service
public 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
The important part is that the child adapter receives the parent context.
@Service
public 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
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
@Service
public 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
This example shows several important Mateu principles:
1. Prefer model-driven UI
The main CRUD is defined from the Order model.
2. Use lookups for references
Customer is represented as a scalar id with search and label suppliers.
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
The child CRUD needs the hydrated orderId, so it is created lazily.
5. Keep boundaries explicit
Nothing is loaded implicitly through List<Entity>.
The parent decides what child UI to embed.
Common mistakes
Mistake 1: using Customer customer
Prefer:
String customerId;
with:
@Lookup(search = CustomerOptionsSupplier.class, label = CustomerLabelSupplier.class)
Mistake 2: using List<OrderLine>
Prefer:
Callable<?> lines = () -> MateuBeanProvider
.getBean(OrderLines.class)
.withOrderId(id);
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
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.