I recently built a custom connector that manages DNS entries. The customer runs critical infrastructure, and DNS is not something you change from just anywhere. So the connector runtime had to run in a secure environment, on a machine that can only be accessed in person. If you want to deploy something there, somebody builds it and walks over with a USB stick, including several flights of stairs.
I wrote the connector as a Spring bean with constructor injection. In the environment where we built and tested it, the runtime had automatic connector discovery disabled (via CONNECTOR_OUTBOUND_DISCOVERY_DISABLED) and everything worked. When the connector moved to the secure machine, nobody carried that environment variable over. The runtime now found the connector twice: once as a Spring bean, and once through SPI. The bean registration was fine. The SPI path tried to build the class on its own, found no no-args constructor, and the runtime didn’t start. The error said the class couldn’t be built. It didn’t say why the runtime was trying to build it in the first place. Every failure meant another walk to the desk, fix and back to the machine, and it took more of those walks than I like to admit before it was clear that discovery wasn’t disabled anymore.
I mixed up the two mechanisms myself. What kept them apart lived in the environment, not in the artifact, and environment variables don’t travel with jars. This article sorts every way to get a custom connector into a Camunda connector runtime into one of two families, so you can pick one on purpose.
Two Families, One Question
The connector runtime collects connectors from several sources and puts them into one factory. After that, it doesn’t matter how a connector got there. Secrets, input validation, retries and error handling all run through the same job handler. The only real difference is who builds the instance:
SPI family: the runtime builds your class itself, reflectively, without Spring.
Spring family: your Spring context builds your class, and the runtime picks up the bean.
Each family has its own rules. A few switches cut across both, and those are the ones that cause confusion.
Two things apply to both families. First, your connector class implements OutboundConnectorFunction, or OutboundConnectorProvider if you use the Operation API. Both families look for exactly these two types. Second, it carries @OutboundConnector. A class without the annotation is skipped silently, by SPI and by bean discovery alike. The runtime doesn’t log an error for it. Jobs of that type just sit there, because no worker picks them up.
Note: I use outbound connectors in the examples, but inbound connectors follow the same rules. Only the names change. The class implements InboundConnectorExecutable and carries @InboundConnector, and the services file is named after that interface. Env var registration uses CONNECTOR_{NAME}_EXECUTABLE, and the discovery switch is CONNECTOR_INBOUND_DISCOVERY_DISABLED. The Spring family has one extra rule for inbound: the bean must be @Scope("prototype"), or the runtime refuses to start.
The SPI Family; The Runtime Builds Your Class
The runtime uses Java’s ServiceLoader. It reads META-INF/services/io.camunda.connector.api.outbound.OutboundConnectorFunction from every jar on the classpath. Each line in that file is the fully qualified name of a connector class. Operation API connectors go into a file named after OutboundConnectorProvider instead.
That file comes into existence in one of two ways. You write it by hand, or, what I didn’t know, the element-template-generator-maven-plugin writes it for you. The plugin does that by default, as long as the file doesn’t exist yet. Both paths end up in the same mechanism, so pick whichever you like. ServiceLoader calls a public no-args constructor, and nothing else. That rules out constructor injection and everything else Spring would normally do for you. The connector builds what it needs itself:
@OutboundConnector(
name = "Invoice Lookup",
type = "com.acme:invoice-lookup:1",
inputVariables = {"invoiceId"})
public class InvoiceLookupConnector implements OutboundConnectorFunction {
private final InvoiceClient invoiceClient = InvoiceClient.fromEnvironment();
@Override
public Object execute(OutboundConnectorContext context) {
var request = context.bindVariables(InvoiceRequest.class);
return invoiceClient.findById(request.invoiceId());
}
}The services file contains a single line:
com.acme.invoice.InvoiceLookupConnectorThere is a sibling in this family: registration by environment variable. You set CONNECTOR_INVOICELOOKUP_FUNCTION=com.acme.invoice.InvoiceLookupConnector, plus _TYPE, _INPUT_VARIABLES and _TIMEOUT if you need them. The runtime loads the class by name and instantiates it reflectively, again without Spring. As soon as one of these variables exists, the runtime skips outbound SPI scanning completely, so env var registration replaces the services files instead of adding to them.
The SPI family is the only one that works with the official connectors Docker image. You don’t write any Spring Boot application. You put your fat jar on the image’s classpath, as described in the Camunda docs on hosting custom connectors. One thing not to count on with this image: Spring picking up beans from your jar. The image only scans its own packages, so a @Component in your package is just a class to it.
The Spring Family; Your Context Builds Your Class
If your connector needs beans, you need your own runtime. That’s a Spring Boot application with io.camunda.connector:spring-boot-starter-camunda-connectors as a dependency. Every bean of type OutboundConnectorFunction or OutboundConnectorProvider in that context gets registered as a connector. The starter doesn’t replace the SPI family, though. It still runs SPI and env var discovery and adds the beans on top, all into the same factory. Now constructor injection works, and the invoice connector looks like this:
@Component
@OutboundConnector(
name = "Invoice Lookup",
type = "com.acme:invoice-lookup:1",
inputVariables = {"invoiceId"})
public class InvoiceLookupConnector implements OutboundConnectorFunction {
private final InvoiceClient invoiceClient;
public InvoiceLookupConnector(InvoiceClient invoiceClient) {
this.invoiceClient = invoiceClient;
}
@Override
public Object execute(OutboundConnectorContext context) {
var request = context.bindVariables(InvoiceRequest.class);
return invoiceClient.findById(request.invoiceId());
}
}Look at the constructor. There is no no-args variant, and that’s exactly why SPI cannot load this class. For the Spring family, the services file must not exist for this connector. The clean way is to tell the plugin not to write it:
<plugin>
<groupId>io.camunda.connector</groupId>
<artifactId>element-template-generator-maven-plugin</artifactId>
<version>${version.connectors}</version>
<executions>
<execution>
<goals>
<goal>generate-templates</goal>
</goals>
</execution>
</executions>
<configuration>
<connectors>
<connector>
<connectorClass>com.acme.invoice.InvoiceLookupConnector</connectorClass>
<writeMetaInfFileGeneration>false</writeMetaInfFileGeneration>
</connector>
</connectors>
</configuration>
</plugin>The flag sits on each <connector> entry, not on the plugin. Older examples in the plugin README use a <connectorClasses> element, but the real one is <connectors><connector>. Camunda does the same thing for its own AI Agent connector, whose components are Spring beans.
There is a catch. The plugin writes the file into src/main/resources, your source tree, and it only writes it if it doesn’t exist yet. Switching the flag to false stops new files. It does not remove the one from your last build, and that one is probably in Git by now. Delete it by hand. The same goes for renames: the file keeps the old class name. A services file that names a class that doesn’t exist, or one that doesn’t implement the interface, stops the runtime at startup. Only a listed class without @OutboundConnector gets skipped silently.
The other way is to switch off discovery for the whole runtime and keep the plugin defaults:
env:
- name: CONNECTOR_OUTBOUND_DISCOVERY_DISABLED
value: "true"This disables SPI and environment variable registration, but never Spring beans. It also disables SPI for every other connector jar on that runtime’s classpath. And it’s the variable that got lost in my story. I’d use it as a second line of defence only. It is an environment variable and nothing else: there is no Spring property for it, so application.yaml won’t help you.
One more advanced gotcha, new in 8.10: the runtime doesn’t use the instance Spring already created for your bean. When it opens the job worker, it builds its own instance from the class, with constructor injection, and reuses that one instance for every job (one per tenant, if you run several). A @Bean factory method is skipped in that step. If you declare the connector itself through a @Bean method, mark it @Scope("prototype"), and the runtime asks Spring for the bean instead. In 8.9, the runtime still used the bean from the context.
Why Blending Breaks
With both families active for the same connector, you can end up in several places:
Constructor injection, SPI file present: SPI tries to build the class and fails, because there is no no-args constructor. The runtime doesn’t start. That was my case.
No-args constructor, SPI file present, also a bean: both registrations succeed, and the runtime refuses to start. Registering the same connector type twice throws. There is no “last one wins”, no matter in which order the runtime reads its sources.
Field injection, SPI only: if the class has
@Autowiredfields and a default constructor,ServiceLoaderbuilds it happily. The fields staynulluntil the first job hits them.Discovery “re-enabled” with
false: the runtime only checks whetherCONNECTOR_OUTBOUND_DISCOVERY_DISABLEDexists. Its value is ignored. Set it tofalseand discovery stays off, and every SPI-only connector on that runtime disappears without a word.Type overrides on a mixed runtime:
CONNECTOR_{NAME}_TYPEis looked up by the connector’s name. SPI and bean registrations of the same class share that name, so they get the same type and you’re back at the duplicate error. Env var registration is different, because you pick the name yourself. Register a bean and the same class by env var under another name with its own_TYPE, and both start. Now two workers handle job types you never meant to have.
Two switches really do apply to both families: CONNECTOR_OUTBOUND_DISABLED and, since 8.10, CONNECTOR_OUTBOUND_ENABLED. Each takes a comma-separated list of connector types, meaning the job types such as com.acme:invoice-lookup:1, not connector names or classes. _DISABLED is a block list, _ENABLED an allow list, and setting both stops the runtime at startup. They filter every source, and they match the type after any _TYPE override. A filtered connector stays registered but gets no job worker. That’s also why they can’t fix a duplicate: the duplicate check runs first, so blocking one of two registrations of the same type still ends in the startup error. Like the discovery switch, they’re environment variables only.
Which Family To Pick
Start with one question: does the connector need beans from a Spring context? That might be a client with connection pooling, or a component another team already maintains as a bean. If yes, you’re in the Spring family. There is no trick that gets injection into SPI. If no, both families work, and the question becomes who runs the runtime.
The SPI family is cheaper to operate. You ship a jar, and Camunda ships the runtime. A Camunda upgrade means a new base image tag and a rebuild. You pay for that in code: the connector builds its own clients and reads its own configuration, and testing that wiring is on you.
The Spring family turns the runtime into your application. You pick the starter version, keep it in line with your Camunda version, build the image and patch its dependencies. For a team that already runs Spring Boot services, that’s routine. For a team that only wanted to add one connector, it’s one more application to maintain.
It gets harder when one runtime should host both. Say three connectors are plain SPI jars and one needs beans. You can put all four into your own Spring runtime, and SPI keeps working there as long as discovery stays on. That rules out the discovery switch, so writeMetaInfFileGeneration=false on the bean connector is your only guard. The same applies if your runtime pulls in Camunda’s out-of-the-box connectors as dependencies, because those register through SPI too. The alternative is two runtimes: the stock image for the SPI connectors, and your own runtime for the bean connector, with discovery disabled as a second line of defence. That’s one more deployment. In return, each runtime follows one family, and the stock one has no variable to lose. If you can afford the extra deployment, I’d split.
Summary
Pick the family per connector, and per runtime where you can. Then make the build enforce it, so the choice doesn’t depend on a variable someone has to remember.
If you need Spring beans, use the Spring family: your own Spring Boot runtime with the starter,
writeMetaInfFileGeneration=falseon the connector, no services file insrc/main/resources. Disabling discovery as well is a backup, and it shouldn’t be your only guard.If you want to drop a jar into the stock image, use the SPI family: public no-args constructor, no injection, plugin defaults or a handwritten services file, jar on the image’s classpath.
If one runtime hosts both families, the build flag is your only guard, because disabling discovery would drop the SPI connectors too. Two runtimes cost a deployment and keep each family on its own.
CONNECTOR_OUTBOUND_DISCOVERY_DISABLEDturns off SPI and environment variable registration, never beans. The presence of the variable is enough, the value doesn’t matter (incl. false).Same type registered twice means the runtime doesn’t start. Nothing overrides anything.
Inbound connectors follow the same rules under their own names. Inbound beans must be prototype-scoped.
If I had put writeMetaInfFileGeneration=false into the DNS connector’s build from day one, the USB stick would’ve gone down those stairs once.


