Managed Objects in OfficeFloor
This tutorial introduces OfficeFloor Managed Objects: the native unit of state management inside the OfficeFloor runtime.
The primary value of managed objects is the ability to implement ManagedObjectSource, which makes the object an active participant in OfficeFloor's execution model. While a Spring bean is always a passive, ready-made value, a ManagedObjectSource can:
- Declare flows — named outputs that OfficeFloor wires to downstream functions in the YAML pipeline. The source triggers them via ManagedObjectExecuteContext, passing a typed argument and an optional completion callback.
- Signal asynchronous completion — use ManagedObjectUser.setManagedObject() to hand the constructed object back to OfficeFloor asynchronously. The function pipeline pauses without blocking any thread and resumes the moment the object is ready.
- Declare dependencies on other managed objects or Spring beans, composing managed objects into typed graphs.
- Run startup logic via
start(ManagedObjectExecuteContext)to open connections or register listeners when the application starts.
The tutorial shows two endpoints: one using the ClassManagedObjectSource shorthand (via class: in YAML) for a plain POJO, and one using a custom AbstractManagedObjectSource (via source: in YAML) that declares a flow output.
Maven dependency
<dependency>
<groupId>net.officefloor.springboot</groupId>
<artifactId>officefloor-rest-spring-boot-4-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Application class
@SpringBootApplication
public class SpringRestManagedObjectApplication {
public static void main(String[] args) {
SpringApplication.run(SpringRestManagedObjectApplication.class, args);
}
}
Scopes
The scope field in the YAML controls how long one instance lives and which parts of the request pipeline share it:
| Scope | One instance lives for … | Typical use |
|---|---|---|
PROCESS |
The duration of one request. All functions (pipeline steps) servicing that request share the same instance. | Correlation IDs, per-request audit logs, request-scoped caches — anything that must be consistent across all steps of a single request. |
THREAD |
The lifetime of one thread participating in the request. When a request is handed off across Teams, each thread gets its own instance. | Per-thread resources that must not cross thread boundaries during a single request's execution. |
FUNCTION |
The duration of one function (step) invocation. Even within the same request, each pipeline step gets a fresh instance. | Step-local scratch state that must not persist beyond a single function call. |
Both examples in this tutorial use PROCESS scope: a new instance is created when each request arrives and shared across every step that handles that request.
Example 1 — class: (ClassManagedObjectSource)
The class: YAML shorthand delegates to ClassManagedObjectSource, which instantiates the named class with its default constructor. This is the right choice for a plain POJO that needs no flows or asynchronous behaviour.
The managed object
RequestContext generates its own correlation ID and start timestamp at construction. No Spring annotations, no framework imports:
public class RequestContext {
private final String correlationId = UUID.randomUUID().toString();
private final Instant startTime = Instant.now();
public String getCorrelationId() {
return correlationId;
}
public Instant getStartTime() {
return startTime;
}
}
YAML configuration
managed-object:
class: net.officefloor.tutorial.springrestmanagedobject.RequestContext
scope: PROCESS
Drop any number of YAML files into officefloor/managedobjects/; the starter picks them up automatically at startup.
Service method
OfficeFloor resolves RequestContext by type, the same way it resolves Spring beans:
public class RequestContextService {
public void service(RequestContext context, ObjectResponse<RequestContextResponse> response) {
response.send(new RequestContextResponse(
context.getCorrelationId(),
context.getStartTime().toEpochMilli()));
}
}
REST endpoint
service:
class: net.officefloor.tutorial.springrestmanagedobject.RequestContextService
Response
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RequestContextResponse {
private String correlationId;
private long startTimeMs;
}
A typical response:
{
"correlationId" : "a3f9c2d1-7b4e-4f0a-9c6d-e1b2f3a4d5e6",
"startTimeMs" : 1749200000000
}Example 2 — source: (custom AbstractManagedObjectSource)
When a managed object needs to declare flows or perform asynchronous setup, implement AbstractManagedObjectSource directly and reference the class via source: in the YAML. None is used as the type parameter for the dependency keys when the source has no dependencies.
The managed object value class
public class SessionId {
private final String id = UUID.randomUUID().toString();
public String getId() {
return id;
}
}
The ManagedObjectSource
SessionIdSource extends AbstractManagedObjectSource with None for dependencies (no other objects required) and Flows for the one declared output:
public class SessionIdSource extends AbstractManagedObjectSource<None, SessionIdSource.Flows> {
public enum Flows {
LOG
}
@Override
protected void loadSpecification(SpecificationContext context) {
}
@Override
protected void loadMetaData(MetaDataContext<None, Flows> context) throws Exception {
// Declare the type this source provides
context.setObjectClass(SessionId.class);
// Declare the LOG flow: argument is the session ID string to be logged.
// Wire this flow via outputs: LOG: <function-name> in the YAML to connect
// it to a downstream function. The source triggers it via
// ManagedObjectExecuteContext.invokeStartupProcess() or the managed object
// signals it via ManagedObjectUser.setManagedObject() for async completion.
context.addFlow(Flows.LOG, String.class);
}
@Override
protected ManagedObject getManagedObject() throws Throwable {
SessionId sessionId = new SessionId();
return () -> sessionId;
}
}
OfficeFloor requires every flow declared via context.addFlow() to be wired to a handler in the YAML. A source triggers the flow at runtime via ManagedObjectExecuteContext.invokeStartupProcess() (on application start) or via ManagedObjectUser.setManagedObject() for per-request asynchronous completion.
The flow handler
SessionLogService is wired to the LOG flow. The @Parameter annotation receives the typed argument the source passes when it triggers the flow:
public class SessionLogService {
public void log(@Parameter String sessionId) {
// In production: write to audit log, metrics, distributed trace, etc.
// The source triggers this function via ManagedObjectExecuteContext,
// passing the session ID as the typed flow argument.
}
}
YAML configuration
The outputs: map wires each declared flow name to a handler function defined in the same YAML file:
managed-object:
source: net.officefloor.tutorial.springrestmanagedobject.SessionIdSource
scope: PROCESS
outputs:
LOG: logSession
logSession:
class: net.officefloor.tutorial.springrestmanagedobject.SessionLogService
Service method
public class SessionIdService {
public void service(SessionId sessionId, ObjectResponse<String> response) {
response.send(sessionId.getId());
}
}
REST endpoint
service:
class: net.officefloor.tutorial.springrestmanagedobject.SessionIdService
Testing
@SpringBootTest
@AutoConfigureMockMvc
public class SpringRestManagedObjectHttpServerTest {
@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper mapper;
// --- class: (ClassManagedObjectSource) example ---
@Test
public void requestContextIsPopulated() throws Exception {
mvc.perform(get("/request").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.correlationId").isString())
.andExpect(jsonPath("$.startTimeMs").isNumber());
}
@Test
public void eachRequestGetsFreshManagedObjectInstance() throws Exception {
MvcResult first = mvc.perform(get("/request").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andReturn();
MvcResult second = mvc.perform(get("/request").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andReturn();
RequestContextResponse r1 = mapper.readValue(
first.getResponse().getContentAsString(), RequestContextResponse.class);
RequestContextResponse r2 = mapper.readValue(
second.getResponse().getContentAsString(), RequestContextResponse.class);
assertNotNull(r1.getCorrelationId(), "correlationId must be present");
assertNotNull(r2.getCorrelationId(), "correlationId must be present");
// PROCESS scope: each incoming request gets a new managed object instance,
// so the correlation IDs generated at construction time must differ.
assertNotEquals(r1.getCorrelationId(), r2.getCorrelationId(),
"PROCESS-scoped managed object must be a new instance per request");
}
@Test
public void startTimeIsReasonable() throws Exception {
long before = System.currentTimeMillis();
MvcResult result = mvc.perform(get("/request").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andReturn();
long after = System.currentTimeMillis();
RequestContextResponse response = mapper.readValue(
result.getResponse().getContentAsString(), RequestContextResponse.class);
assertTrue(response.getStartTimeMs() >= before,
"startTimeMs must be at or after the request was sent");
assertTrue(response.getStartTimeMs() <= after,
"startTimeMs must be at or before the response was received");
}
// --- source: (custom ManagedObjectSource) example ---
@Test
public void customManagedObjectSourceIsInjected() throws Exception {
mvc.perform(get("/session"))
.andExpect(status().isOk());
}
@Test
public void customManagedObjectSourceProvidesUniqueInstancePerRequest() throws Exception {
String id1 = mvc.perform(get("/session"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
String id2 = mvc.perform(get("/session"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertNotNull(id1, "session ID must be present");
assertNotNull(id2, "session ID must be present");
// PROCESS scope: SessionIdSource creates a new SessionId per request,
// so two successive requests must carry different IDs.
assertNotEquals(id1, id2,
"Custom ManagedObjectSource with PROCESS scope must provide a new instance per request");
}
}
The class: tests confirm that PROCESS scope delivers a freshly-constructed RequestContext per request — two successive calls produce different correlation IDs — and that the start timestamp falls within the wall-clock window of the request.
The source: tests confirm that SessionIdSource is wired correctly — the endpoint responds successfully — and that PROCESS scope gives each request a distinct SessionId instance.
What you have now
After completing this tutorial you can:
- Understand that the primary value of ManagedObjectSource is enabling flows and asynchronous completion managed by the OfficeFloor framework
- Use the
class:shorthand (backed by ClassManagedObjectSource) for plain POJOs that need no flows - Extend AbstractManagedObjectSource with None for unused key parameters, and declare flow outputs via
context.addFlow() - Wire flows to handler functions in the YAML via
outputs:and receive typed arguments via @Parameter - Choose
PROCESS,THREAD, orFUNCTIONscope to match the intended lifetime: per-request, per-thread-within-request, or per-function-step - Inject any managed object into service methods by type, alongside Spring beans, with no annotation required on either the class or the method
Next
The Supplier tutorial demonstrates registering a library of related managed objects from a single YAML declaration using a SupplierSource — ideal for third-party library integration.

