Originally published on kuryzhev.cloud
When you face this choice
The Jenkins shared library structure question usually shows up the moment your third team copies a Jenkinsfile from your first team. You've got five repos now, each with a nearly identical stage('Build Docker') block, and someone just fixed a registry login bug in repo #2 without touching repos #1, #3, #4, or #5. Nobody notices until a deploy fails three weeks later with a stale credential error nobody can reproduce locally.
That's the trigger. Once you've got 3+ pipelines duplicating the same logic, you extract it into a shared library — and then you hit a fork in the road that every team using the Pipeline: Shared Groovy Libraries plugin eventually hits: do you put your logic in vars/*.groovy as Global Variables, or do you push it into src/**/*.groovy as real Groovy classes and keep vars/ as a thin entry point?
This isn't a cosmetic decision. I've seen teams treat it like a style preference and regret it within a year. The structure you pick determines whether you can unit test your pipeline logic, how much blast radius a bad commit has across every consuming Jenkinsfile, and whether onboarding a fifth or tenth team is a five-minute conversation or a week of confused Slack threads. I've built both, maintained both in production, and I have a strong opinion — which I'll get to.
Option A — vars/-only convention (Global Variables)
The vars/-only approach means every reusable step — buildDockerImage(), notifySlack(), deployToK8s() — is a Groovy script sitting directly in vars/ with a call() method. It's the path of least resistance, and honestly, it's how most shared libraries start.
Pros: zero boilerplate. You write vars/deployApp.groovy, define call(Map config), and it's immediately usable as deployApp(env: 'staging') in any Jenkinsfile that imports the library. There's a clean 1:1 mapping between the file name and the pipeline step name, which makes the codebase readable to anyone who already knows Jenkinsfile syntax — no Java/Groovy OOP background required. For prototyping a new shared step, this is the fastest option by far. I've written a working vars/ script in under ten minutes more times than I can count.
Cons: there's no real object-oriented design here. No inheritance, no interfaces, no dependency injection. If two scripts need to share state, you end up hacking it through env variables or global maps passed around like hot potatoes. Worse — unit testing a vars/ script means mocking the entire pipeline DSL (sh, withCredentials, input, all of it), which gets painful fast once the logic branches more than a few times.
Failure mode at scale: I watched a vars/deployApp.groovy grow to 400+ lines because every new requirement — canary logic, rollback, Slack notifications, feature flags — got bolted onto the same call() method. It became the exact monolith the team extracted the library to avoid. Merge conflicts came back with a vengeance, just one directory level up from where they started.
Option B — src/-driven class structure with thin vars/ wrappers
The alternative treats the shared library like an actual codebase: real classes in src/org/pkgname/, with vars/ reduced to a handful of lines that instantiate and delegate. Think DockerBuilder, SlackNotifier, K8sDeployer — each a standalone class with constructor-injected steps context.
Pros: this is where testability actually becomes possible. With JenkinsPipelineUnit 1.19+, or Spock if your team prefers it, you can unit test a DockerBuilder class in complete isolation — no live Jenkins master, no waiting for a build queue. Constructor injection means you're not fighting global state; each class gets exactly the context it needs. Code review also gets dramatically better: a diff to a 40-line class method is something a reviewer can actually reason about, versus a diff buried in a 400-line script.
Cons: the learning curve is real for teams that don't have a Java/Groovy background. You'll also run into CPS transformation gotchas — Jenkins Pipeline runs Groovy through a continuation-passing-style transform for durability across restarts, and classes that don't play nice with it throw java.io.NotSerializableException the moment a build pauses at an input step and Jenkins needs to serialize the whole call stack. The fix is usually @NonCPS annotations or marking fields transient, but it's a debugging session most teams don't expect on day one. There are also just more files to navigate — a one-line config tweak might mean touching three files instead of one.
Where it pays off: once you're past 10+ consuming pipelines across multiple teams, the compile-time-ish safety of real classes and the ability to actually write tests before merging changes outweighs the extra navigation cost. I've seen this prevent at least two org-wide outages that a vars/-only structure would have shipped straight to every pipeline simultaneously.
Decision matrix
Here's how I actually score this when a team asks me to make the call. Weight these against your real numbers, not aspirational ones.
| Criteria | vars/-only wins when... | src/-driven wins when... |
|---|---|---|
| Team size | 1-2 teams, single owner | 3+ teams sharing the library |
| CI job count | < 5 consuming Jenkinsfiles | > 10 consuming Jenkinsfiles |
| Need for unit tests | Low — changes are simple, low risk | High — compliance, audit trail required |
| Groovy/OOP proficiency | Team knows Jenkinsfile syntax only | Team comfortable with classes, packages |
| Versioning strategy | Loose, moving fast, prototype phase | Strict semver tags, changelog discipline |
Most teams I've worked with don't land on a pure extreme — and that's fine. The realistic middle ground is what most shared libraries look like after 12 months in production: a handful of trivial one-liner steps stay in vars/ (things like notifySlack() that genuinely don't need a class), while anything with branching logic, external dependencies, or multi-step state gets pushed into src/. Pure vars/-only libraries rarely survive past the 400-line monolith stage. Pure src/-only libraries — with zero thin wrappers — are rare because someone always needs a quick throwaway step and doesn't want to write a class for it.
My pick
I'll say it plainly: thin vars/ wrappers (10-20 lines, no logic beyond instantiation and delegation) backed by real src/ classes is the only structure I recommend for anything beyond a two-week prototype. It gives you the readable entry point everyone expects from a Jenkinsfile, without letting logic sprawl into an untestable monolith. Below is the layout and code I actually use.
// --- Directory structure (comment-only reference) ---
// ci-shared-lib/
// ├── vars/
// │ └── buildDockerImage.groovy <- thin entrypoint, calls into src/
// ├── src/
// │ └── com/kuryzhev/ci/
// │ └── DockerBuilder.groovy <- actual logic, unit-testable
// └── resources/
// └── com/kuryzhev/ci/templates/Dockerfile.tpl
// ===== vars/buildDockerImage.groovy =====
def call(Map config = [:]) {
// 'this' is the pipeline script context — pass it explicitly to the class
def builder = new com.kuryzhev.ci.DockerBuilder(this, config)
builder.build()
builder.push()
}
// ===== src/com/kuryzhev/ci/DockerBuilder.groovy =====
package com.kuryzhev.ci
class DockerBuilder implements Serializable {
def steps // injected pipeline context (sh, echo, etc.)
String imageName
String registry
String tag
DockerBuilder(steps, Map config) {
this.steps = steps
this.imageName = config.imageName ?: error('imageName is required')
this.registry = config.registry ?: 'registry.kuryzhev.cloud'
this.tag = config.tag ?: steps.env.BUILD_NUMBER
}
void build() {
// libraryResource pulls the templated Dockerfile from resources/
def dockerfile = steps.libraryResource('com/kuryzhev/ci/templates/Dockerfile.tpl')
steps.writeFile file: 'Dockerfile.generated', text: dockerfile
steps.sh "docker build -t ${registry}/${imageName}:${tag} -f Dockerfile.generated ."
}
void push() {
// credentials() binding — never hardcode registry creds in resources/
steps.withCredentials([steps.usernamePassword(
credentialsId: 'docker-registry-creds',
usernameVariable: 'DOCKER_USER',
passwordVariable: 'DOCKER_PASS'
)]) {
steps.sh "echo \$DOCKER_PASS | docker login ${registry} -u \$DOCKER_USER --password-stdin"
steps.sh "docker push ${registry}/${imageName}:${tag}"
}
}
}
// ===== Consuming Jenkinsfile =====
@Library('ci-shared-lib@v2.3.1') _ // pinned version, NOT @main
pipeline {
agent any
stages {
stage('Build & Push') {
steps {
buildDockerImage(imageName: 'checkout-service', tag: env.GIT_COMMIT.take(7))
}
}
}
}
Two non-negotiables come with this pick. First: pin the library version with git tags — @Library('ci-shared-lib@v2.3.1') _ — and never reference @main or @master in a production Jenkinsfile. I've watched a single bad commit to a shared library silently break every consuming pipeline org-wide within minutes, simply because nobody had pinned a version. This one habit alone prevents most "why did prod break at 2am" incidents. Second: mandate JenkinsPipelineUnit tests before merge. Without tests, a shared library isn't infrastructure — it's untested code with an organization-wide blast radius.
Here's the test that would have caught a regression in DockerBuilder before it ever reached a consuming pipeline:
// ===== test/groovy/com/kuryzhev/ci/DockerBuilderTest.groovy =====
// Run with JenkinsPipelineUnit 1.19 — no live Jenkins master needed
import com.lesfurets.jenkins.unit.BasePipelineTest
import com.kuryzhev.ci.DockerBuilder
import org.junit.Test
import static org.junit.Assert.assertTrue
class DockerBuilderTest extends BasePipelineTest {
@Test
void 'build generates correct docker build command'() {
def config = [imageName: 'checkout-service', tag: 'abc1234']
def builder = new DockerBuilder(binding.getVariable('steps'), config)
builder.build()
// helper.callStack captures every mocked step invocation
def shCalls = helper.callStack.findAll { it.methodName == 'sh' }
assertTrue(shCalls.any { it.args[0].toString().contains('checkout-service:abc1234') })
}
@Test(expected = Exception)
void 'missing imageName throws error'() {
new DockerBuilder(binding.getVariable('steps'), [:])
}
}
// Expected console output on `mvn test` / `gradle test`:
//
// DockerBuilderTest > build generates correct docker build command PASSED
// DockerBuilderTest > missing imageName throws error PASSED
//
// BUILD SUCCESSFUL in 2s
Watch out for: if you skip the constructor injection pattern and let a src/ class call sh or env directly without passing steps in, you'll hit groovy.lang.MissingPropertyException: No such property: steps the first time it's actually invoked inside a pipeline — it works fine in isolation until it doesn't. And if a field on your class holds something non-serializable across an input or long-running sh step, expect NotSerializableException the moment Jenkins tries to persist pipeline state mid-build. Mark it transient or move the logic into a @NonCPS method.
One more thing worth flagging on the security side: don't dump credentials into resources/ files bundled with the library repo. That's a fast way to leak secrets into git history permanently. Use Jenkins' credentials() binding every time, as shown in the push() method above. And when the sandbox rejects an unapproved Groovy method call, resist the urge to blanket-approve everything in Manage Jenkins → In-process Script Approval — audit each approval individually, especially anything touching @Grab or external classloaders.
For teams sitting at more than 50 builds a day, also be deliberate about how the library loads. Loading a heavyweight @Library implicitly at the top of every Jenkinsfile means a fresh checkout and Groovy compile on every single build — that adds up. Reserve implicit loading for libraries genuinely used everywhere, and lazy-load the rest with the library 'name' step inside the specific stage that needs it. We've covered similar CI/CD pipeline hardening patterns over on kuryzhev.cloud's CI/CD category if you want more war stories from this side of the pipeline.
Get the shared library structure right once, pin your versions, write the tests, and this stops being a recurring 2am problem. Get it wrong, and every team you onboard multiplies the risk instead of the value. Full reference on the plugin mechanics is in the official Jenkins shared libraries documentation — worth reading end to end before you commit to a structure.
Top comments (0)