Skip to content

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.


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

Use:

  • @Lookup for 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.


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

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
}

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

The lookup has two parts:

  • LookupOptionsSupplier → searches available customers
  • LabelSupplier → resolves selected customer labels

@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()
);
}
}

@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("?");
}
}

@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);
}
}

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.


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.


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";
}
}

@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;
}
}

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);
}
};
}
}

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

@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);
}
}

This example shows several important Mateu principles:

The main CRUD is defined from the Order model.

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.

Nothing is loaded implicitly through List<Entity>.

The parent decides what child UI to embed.


Prefer:

String customerId;

with:

@Lookup(search = CustomerOptionsSupplier.class, label = CustomerLabelSupplier.class)

Prefer:

Callable<?> lines = () -> MateuBeanProvider
.getBean(OrderLines.class)
.withOrderId(id);

For standard CRUD, start with the model and orchestrator.

Create explicit pages only when you need custom composition or custom flows.


The Mateu way for this example is:

  • Orders extends AutoCrudOrchestrator<Order>
  • Order.customerId uses @Lookup
  • OrderLines 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.