OfficeFloor extends Spring's dependency injection with two further kinds of injection. The previous tutorials covered dependency injection — Spring beans injected into method parameters. This tutorial introduces function injection: composing plain Java methods into a request-handling pipeline declared entirely in a YAML file.
Each method handles exactly one concern and knows nothing about the others. The YAML file is the complete specification — it names the classes, their order, and how data flows between them. This makes the entire pipeline readable without opening any Java file, which is especially valuable when working with AI coding tools.
The tutorial exposes a POST /order endpoint with three composed steps:
validate — checks that productId is present and quantity is positive; short-circuits on invalid inputprice — calculates the order total using a Spring PricingService; returns a PricedOrdersave — persists the order using a Spring OrderService and writes the OrderResponse<dependency> <groupId>net.officefloor.springboot</groupId> <artifactId>officefloor-rest-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
No changes to the standard Spring Boot application class are needed:
@SpringBootApplication
public class SpringRestFunctionApplication {
public static void main(String[] args) {
SpringApplication.run(SpringRestFunctionApplication.class, args);
}
}
The entire pipeline for POST /order lives in one file:
validate:
class: net.officefloor.tutorial.springrestfunction.ValidateOrderLogic
outputs:
valid: price
price:
class: net.officefloor.tutorial.springrestfunction.CalculatePricingLogic
next: save
save:
class: net.officefloor.tutorial.springrestfunction.SaveOrderLogic
Reading the file top-to-bottom is reading the specification. Each entry is a named step. The class key names the Java class that implements it. outputs maps a conditional branch name to its target step. next names the unconditional continuation.
None of the three Java classes imports or references the others. Changing the pipeline — adding a logging step, reordering, bypassing a step in staging — is a YAML edit with no Java changes.
@FlowThe validate step is the entry point. It receives the request body and decides whether to proceed or short-circuit. The conditional branch is expressed as a custom functional interface annotated with @Flow:
public class ValidateOrderLogic {
@FunctionalInterface
public interface ValidOrderFlow {
void flow(OrderRequest order);
}
public void service(
@RequestBody OrderRequest request,
@Flow("valid") ValidOrderFlow validFlow,
ObjectResponse<OrderResponse> response) {
if (request.getProductId() == null || request.getProductId().isBlank()
|| request.getQuantity() <= 0) {
response.send(new OrderResponse(null, request.getProductId(), request.getQuantity(), 0.0));
} else {
validFlow.flow(request);
}
}
}
@Flow("valid") tells OfficeFloor that calling validFlow.flow(order) routes execution to whatever step is mapped to valid in the YAML — here, price. The OrderRequest passed to flow() is automatically available as a @Parameter in the receiving step.
When the request is invalid, response.send(...) is called directly and the price and save steps are never invoked for that request.
The price step receives the validated OrderRequest as a @Parameter and uses a Spring PricingService to calculate the total. The return value becomes the @Parameter for the next step:
public class CalculatePricingLogic {
public PricedOrder price(
@Parameter OrderRequest order,
PricingService pricingService) {
double total = pricingService.calculateTotal(order.getProductId(), order.getQuantity());
return new PricedOrder(order.getProductId(), order.getQuantity(), total);
}
}
Returning a value and using next in the YAML is the lightweight alternative to @Flow when there is no branching. The returned PricedOrder is a plain data carrier — it holds exactly the information the next step needs, nothing more.
The save step receives the PricedOrder, delegates persistence to a Spring OrderService, and writes the final response:
public class SaveOrderLogic {
public void save(
@Parameter PricedOrder order,
OrderService orderService,
ObjectResponse<OrderResponse> response) {
String orderId = orderService.createOrder(order.getProductId(), order.getQuantity(), order.getTotal());
response.send(new OrderResponse(orderId, order.getProductId(), order.getQuantity(), order.getTotal()));
}
}
Both services are plain Spring beans — annotated with @Service and injected automatically:
@Service
public class PricingService {
private static final double UNIT_PRICE = 9.99;
public double calculateTotal(String productId, int quantity) {
return quantity * UNIT_PRICE;
}
}
@Service
public class OrderService {
private final AtomicInteger sequence = new AtomicInteger(1);
public String createOrder(String productId, int quantity, double total) {
return "ORD-" + sequence.getAndIncrement();
}
}
The request and response POJOs carry data into and out of the pipeline:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrderRequest {
private String productId;
private int quantity;
}
@Data
@AllArgsConstructor
public class PricedOrder {
private String productId;
private int quantity;
private double total;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrderResponse {
private String orderId;
private String productId;
private int quantity;
private double total;
}
Each step is a plain Java class — unit-testable without any framework by simply instantiating it and calling the method. Integration tests use the standard Spring Boot MockMvc approach:
@SpringBootTest
@AutoConfigureMockMvc
public class SpringRestFunctionHttpServerTest {
@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper mapper;
@Test
public void validOrderFlowsThroughPipeline() throws Exception {
mvc.perform(post("/order")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new OrderRequest("PROD-1", 3)))
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.orderId").value("ORD-1"))
.andExpect(jsonPath("$.productId").value("PROD-1"))
.andExpect(jsonPath("$.quantity").value(3))
.andExpect(jsonPath("$.total").value(3 * 9.99));
}
@Test
public void invalidOrderStopsAtValidation() throws Exception {
mvc.perform(post("/order")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new OrderRequest("", 0)))
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.orderId").isEmpty())
.andExpect(jsonPath("$.total").value(0.0));
}
}
validOrderFlowsThroughPipeline confirms the full pipeline ran: the order ID is assigned, the product and quantity are echoed, and the total is the expected price. invalidOrderStopsAtValidation confirms the short-circuit: orderId is null (no order was created) and total is zero (the pricing step never ran).
After completing this tutorial you can:
@Flow with a custom functional interface for conditional branchingnext for sequential continuation@ParameterThe pipeline is the YAML. The Java classes are independent units. Adding, reordering, or removing a step is a YAML change only.
The Spring REST Exception Handling tutorial covers method escalations, composition escalations, and Spring @ControllerAdvice integration.