Part 3: Workflows and Modules
Strict syntax removes the addParams(...) and params(...) clauses from include statements. A module's configuration must now be visible at the call site through explicit workflow inputs. This is the single most common change real nf-core PRs need to make.
Learning goals
- Remove
addParams/paramsclauses fromincludestatements. - Expose a module's runtime knobs as
take:inputs on a named sub-workflow. - Stop reading
params.*from inside processes — read inputs instead.
Theory: includes import components, they do not configure them
Before 26.04 it was idiomatic to pass values through an include statement:
include { RENAME_FASTQ } from './modules/rename_fastq' addParams(suffix: params.suffix)
This worked, but it hid the module's interface at import time and tied the caller to the importee's params namespace. Three concrete problems compound in real pipelines:
- The interface is invisible at the call site. A reader has to open the included file to know which
params.*it expects. - Two callers cannot configure the same module differently in the same script — both
includestatements share the sameparamsmap. - Static typing cannot help. Once Part 6's typed interfaces land, only inputs declared in
take:get checked. Anything smuggled in throughparamsstays unchecked.
Strict syntax forces the interface to surface: the module exports a named workflow with take: inputs, and the caller passes values explicitly — just like calling a function.
nf-core naming convention used in this workshop
Both processes and named (sub-)workflows are written in UPPERCASE_WITH_UNDERSCORES, matching the nf-core modules repository. To keep the two distinct in the same module file, this workshop uses singular for the process (one record at a time) and plural for the workflow (orchestrates many): RENAME_FASTQ is the process, RENAME_FASTQS is the workflow that fans it out. The folder under modules/ stays lowercase, also matching nf-core.
Side-by-side
Two things change at once in this migration, and both are visible in the diff below:
- The module file grows a new wrapper workflow. The process
RENAME_FASTQ(singular) stays where it is; a new sub-workflowRENAME_FASTQS(plural) is added next to it and becomes the module's public entrypoint. - The entry
main.nfswitches what it imports. Before, the only thing the module exported was the process, somain.nfimportedRENAME_FASTQ. After,main.nfimportsRENAME_FASTQS— the new workflow that takessuffixas a proper input.
So the rename in the import is not cosmetic — it reflects that the public surface of the module has changed from a process to a workflow.
// imports the PROCESS directly — only thing this module exports today
include { RENAME_FASTQ } from './modules/rename_fastq' addParams(suffix: params.suffix)
workflow {
main:
RENAME_FASTQ(channel.of('NA12878', 'HG002'))
RENAME_FASTQ.out.view { "created ${it.name}" }
}
// legacy: a process on its own; configuration is smuggled in via params.suffix
process RENAME_FASTQ {
input:
val sample_id
output:
path "${sample_id}.fastq"
script:
"""
printf '%s\n' '${sample_id}_${params.suffix}' > ${sample_id}.fastq
"""
}
params.suffix is read inside the process script, hidden from the caller.
// imports the new WRAPPER WORKFLOW (plural), not the process — the module's public entrypoint moved
include { RENAME_FASTQS } from './modules/rename_fastq'
workflow {
main:
def samples = channel.of('NA12878', 'HG002')
RENAME_FASTQS(samples, params.suffix) // suffix is now an explicit argument
RENAME_FASTQS.out.files.view { file -> "created ${file.name}" }
}
// unchanged unit of work — still the singular process that handles one sample
process RENAME_FASTQ {
input:
tuple val(sample_id), val(suffix)
output:
path "${sample_id}${suffix}.fastq"
script:
"""
printf 'sample=%s\nsuffix=%s\n' '${sample_id}' '${suffix}' > ${sample_id}${suffix}.fastq
"""
}
// NEW: the plural wrapper workflow that the entry main.nf now imports
workflow RENAME_FASTQS {
take:
samples
suffix
main:
def records = samples.map { sample_id -> [sample_id, suffix] }
RENAME_FASTQ(records)
emit:
files = RENAME_FASTQ.out
}
suffix is now a take: input on RENAME_FASTQS. The caller passes params.suffix explicitly; the process reads it from the tuple, not from a global.
The lint error you will see on the legacy version is unambiguous:
Error legacy.nf:3:56: Unexpected input: 'addParams'
│ 3 | include { RENAME_FASTQ } from './modules/rename_fastq' addParams(suffix: ...
╰ | ^
1. Demo
cd code/03-workflows-modules/demo
nextflow run legacy.nf -profile test
Expected output
Error legacy.nf:3:56: Unexpected input: 'addParams'
Now run the migrated entry point:
nextflow run main.nf -profile test
Expected output
[PROCESS …] RENAME_FASTQS:RENAME_FASTQ (1)
[PROCESS …] RENAME_FASTQS:RENAME_FASTQ (2)
created NA12878_R1.fastq
created HG002_R1.fastq
[SUCCESS] completed=2 failed=0 cached=0
The process name appears nested as RENAME_FASTQS:RENAME_FASTQ because the entry workflow now calls the named sub-workflow rather than the process directly. That nesting is a useful sanity check during real migrations — if you see it in the log, your module is correctly hiding its process behind a workflow interface.
Remote module include style
Nextflow 26.04 also improves include behaviour for remote repositories. The strict rule is the same regardless of where the module lives: an include statement may only name what is being imported, never what to configure it with. Part 8 covers the new nextflow module CLI that manages remote dependencies.
2. Exercise
The exercise pipeline merges three lane-tagged FASTQs per sample. The module folder is modules/merge_lanes, the single-record process is MERGE_LANE, and after migration you will add a MERGE_LANES plural workflow alongside it. The params.suffix defaults to _merged. The starting point fails for the same reason as the demo (the include clause), and the process reads params.suffix directly — which is the second piece you must remove.
Note that the migration also changes which symbol you import: today the entry workflow imports the process (include { MERGE_LANE }); after migration it imports the new sub-workflow (include { MERGE_LANES }).
cd ../exercise
nextflow lint -project-dir . main.nf
Expected output
Error main.nf:3:54: Unexpected input: 'addParams'
Migrate main.nf and modules/merge_lanes/main.nf so that:
- the
includestatement has noaddParamsclause, - the module exposes a named sub-workflow
MERGE_LANESwithtake: samplesandtake: suffix, - the process reads
suffixfrom a tuple input, not fromparams.suffixdirectly, - the entry workflow passes
params.suffixtoMERGE_LANESexplicitly.
Then verify:
nextflow run main.nf -profile test
The expected output is three merged HG002_L1_merged.txt / HG002_L2_merged.txt / HG003_L1_merged.txt lines and completed=3 in the summary. Compare with the worked solution only after attempting the migration:
cd ../solution
nextflow run main.nf -profile test
nf-test test tests/*.nf.test
The nf-test target asserts that exactly three tasks ran — that confirms the entry workflow really did fan out to all three lane-tagged samples, not silently dropping any.
Checkpoint
- Every
includestatement in my migrated code only names what is imported. - Each module exposes a named sub-workflow whose
take:block lists every runtime knob. - No process reads
params.*directly — values arrive through the tuple input.
Continue to Part 4.