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.

Tutorial Source

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, or FUNCTION scope 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.