SupplierSource in OfficeFloor
The previous tutorial showed how a single ManagedObjectSource registers one managed object type. This tutorial introduces SupplierSource: a single YAML declaration that registers an entire library of related managed object types from one source.
The distinction matters in practice:
- A Spring bean or individual managed object is always declared independently. If two objects share internal state (a connection pool, a queue, a DI container) there is no clean place to put that shared state — it ends up as a Spring singleton that both beans reference, coupling unrelated configuration.
- A SupplierSource owns the shared state. It registers all the types that expose that state through their own managed object wrappers. Callers simply inject whichever type they need; the supplier ensures they all see the same underlying resource.
Typical use cases:
- Integrating a third-party messaging or streaming library (e.g. Kafka, JMS) that naturally provides producer and consumer as a matched pair sharing a connection.
- Bridging another dependency injection framework (e.g. Guice, CDI) — the supplier wraps that framework's injector and vends its objects as OfficeFloor managed objects.
- Providing a coherent client set for an external service — e.g. a REST client, its retry policy, and its circuit breaker — all wired to the same underlying HTTP connection pool held by the supplier.
The tutorial models an in-memory messaging service. One SupplierSource registers both MessagePublisher and MessageSubscriber, both sharing the same internal queue. The tutorial exposes two endpoints: POST /message to publish and GET /message to receive.
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 SpringRestSupplierApplication {
public static void main(String[] args) {
SpringApplication.run(SpringRestSupplierApplication.class, args);
}
}
The domain types
MessagePublisher and MessageSubscriber are plain Java classes. Their constructors are package-private: only SupplierSource should create them, ensuring every instance is backed by the same shared queue.
public class MessagePublisher {
private final LinkedBlockingDeque<String> queue;
MessagePublisher(LinkedBlockingDeque<String> queue) {
this.queue = queue;
}
public void publish(String content) {
queue.addLast(content);
}
}
public class MessageSubscriber {
private final LinkedBlockingDeque<String> queue;
MessageSubscriber(LinkedBlockingDeque<String> queue) {
this.queue = queue;
}
public String receive() {
return queue.pollFirst();
}
}
The SupplierSource — MessagingSupplierSource
AbstractSupplierSource is extended and SupplierSourceContext.addManagedObjectSource() is called once per type inside SupplierSource.supply().
The shared messageQueue field lives on the supplier instance. Both inline AbstractManagedObjectSource implementations capture it via closure, so every MessagePublisher and every MessageSubscriber created across all requests write to and read from exactly the same deque. None is used for both generic parameters because neither managed object source requires OfficeFloor-managed dependencies or declares flow outputs:
public class MessagingSupplierSource extends AbstractSupplierSource {
private final LinkedBlockingDeque<String> messageQueue = new LinkedBlockingDeque<>();
@Override
protected void loadSpecification(SpecificationContext context) {
}
@Override
public void supply(SupplierSourceContext context) throws Exception {
// Register MessagePublisher — captures the shared queue via closure
context.addManagedObjectSource(null, MessagePublisher.class,
new AbstractManagedObjectSource<None, None>() {
@Override
protected void loadSpecification(SpecificationContext context) {}
@Override
protected void loadMetaData(MetaDataContext<None, None> context) throws Exception {
context.setObjectClass(MessagePublisher.class);
}
@Override
protected ManagedObject getManagedObject() throws Throwable {
return () -> new MessagePublisher(messageQueue);
}
});
// Register MessageSubscriber — captures the same shared queue via closure
context.addManagedObjectSource(null, MessageSubscriber.class,
new AbstractManagedObjectSource<None, None>() {
@Override
protected void loadSpecification(SpecificationContext context) {}
@Override
protected void loadMetaData(MetaDataContext<None, None> context) throws Exception {
context.setObjectClass(MessageSubscriber.class);
}
@Override
protected ManagedObject getManagedObject() throws Throwable {
return () -> new MessageSubscriber(messageQueue);
}
});
}
@Override
public void terminate() {
messageQueue.clear();
}
}
The terminate() method is called when OfficeFloor shuts down, giving the supplier the opportunity to release any resources it holds.
Configuring the supplier — officefloor/suppliers/
Suppliers are declared as YAML files in src/main/resources/officefloor/suppliers/. The source field names the SupplierSource implementation. An optional properties: map is passed to SupplierSource.supply() via the SupplierSourceContext:
source: net.officefloor.tutorial.springrestsupplier.MessagingSupplierSource
One YAML file registers all types that the supplier provides. Drop any number of YAML files into officefloor/suppliers/; each file is one supplier.
Service methods
MessagePublisher and MessageSubscriber are accepted by type — no qualifier, no explicit wiring required. OfficeFloor finds the supplier that registered each type and injects the managed object. The HTTP request body is bound with @RequestBody:
public class PublishService {
public void service(@RequestBody Message message, MessagePublisher publisher) {
publisher.publish(message.getContent());
}
}
public class SubscribeService {
public void service(MessageSubscriber subscriber, ObjectResponse<String> response) {
String content = subscriber.receive();
response.send(content != null ? content : "");
}
}
The request body for POST /message:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Message {
private String content;
}
REST endpoints
service:
class: net.officefloor.tutorial.springrestsupplier.PublishService
service:
class: net.officefloor.tutorial.springrestsupplier.SubscribeService
Testing
@SpringBootTest
@AutoConfigureMockMvc
public class SpringRestSupplierHttpServerTest {
@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper mapper;
@Test
public void publishedMessageIsReceivedBySubscriber() throws Exception {
// Publish returns 204 (No Content) — correct for a fire-and-forget operation
mvc.perform(post("/message")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new Message("hello from supplier"))))
.andExpect(status().is2xxSuccessful());
// Subscriber reads from the same shared queue held by the supplier
mvc.perform(get("/message"))
.andExpect(status().isOk())
.andExpect(content().string("hello from supplier"));
}
@Test
public void multipleMessagesAreQueuedInOrder() throws Exception {
mvc.perform(post("/message")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new Message("first"))))
.andExpect(status().is2xxSuccessful());
mvc.perform(post("/message")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new Message("second"))))
.andExpect(status().is2xxSuccessful());
// Queue preserves FIFO insertion order across requests
mvc.perform(get("/message"))
.andExpect(status().isOk())
.andExpect(content().string("first"));
mvc.perform(get("/message"))
.andExpect(status().isOk())
.andExpect(content().string("second"));
// Queue is now drained — next receive returns empty
mvc.perform(get("/message"))
.andExpect(status().isOk())
.andExpect(content().string(""));
}
}
publishedMessageIsReceivedBySubscriber proves the end-to-end path: a message published in one request is visible to the subscriber in the next. This works because both the publisher and subscriber write to and read from the same deque held by the supplier — not because of any Spring bean or shared static state.
multipleMessagesAreQueuedInOrder confirms that the queue preserves insertion order: two successive publishes followed by two successive receives return messages in FIFO order.
receiveReturnsEmptyWhenQueueIsEmpty covers the empty-queue path.
Note that because the supplier's queue persists for the application lifetime, test order matters: each test should leave the queue empty. publishedMessageIsReceivedBySubscriber and multipleMessagesAreQueuedInOrder each drain every message they publish, so no leakage occurs between tests.
Supplier vs managed object vs Spring bean
| Mechanism | Registers … |
|---|---|
Spring @Bean |
One bean per method; shared state requires a separate singleton bean referenced by each other bean. |
| Managed object YAML | One managed object per YAML file; no built-in way to share state between two separate files. |
| Supplier YAML | Any number of managed object types from one YAML file; the supplier instance holds shared state that all its types access via closure. |
What you have now
After completing this tutorial you can:
- Understand that SupplierSource exists to register a library of related objects from a single YAML declaration, with the supplier instance holding any shared state
- Extend AbstractSupplierSource and call SupplierSourceContext.addManagedObjectSource() once per type inside
supply() - Place a YAML file in
officefloor/suppliers/with asource:field to activate the supplier, optionally passingproperties:for configuration - Inject any type the supplier registers into service methods by type, alongside Spring beans and individually declared managed objects, with no additional annotation
- Implement
terminate()to release resources when OfficeFloor shuts down
Next
The Spring REST Governance tutorial demonstrates how OfficeFloor Governance wraps function execution with framework-managed pre/post lifecycle via YAML configuration — without annotations on the service class.

