501 words, 3 min read

When you first write an Oban worker, it's tempting to hardcode its configuration directly in the module. A worker that fetches an RSS feed might embed the URL as a module attribute. It works fine — until you need to do the same thing for a second feed, and suddenly you're copy-pasting a nearly identical module.

There's a better way.

The Problem: One Worker, One Purpose

A typical first pass at a feed-fetching worker looks something like this:

defmodule MyApp.Workers.FetchThinkingElixirFeedWorker do
use Oban.Worker, queue: :default, max_attempts: 1
@feed_url "https://www.yellowduck.be/posts/feed"
@tags ["elixir", "phoenix"]
@impl Oban.Worker
def perform(%Oban.Job{}) do
# fetch and process @feed_url, apply @tags...
end
end

This is completely fine for one feed. But the moment you want to add a second feed, you're either duplicating the module or reaching for inheritance patterns that don't belong here.

The Fix: Pass Configuration as Job Args

Oban jobs carry an args map that gets persisted alongside the job. Instead of hardcoding configuration in the module, move it into those args:

defmodule MyApp.Workers.FetchFeedWorker do
use Oban.Worker, queue: :default, max_attempts: 1
@impl Oban.Worker
def perform(%Oban.Job{args: %{"feed_url" => feed_url, "tags" => tags}}) do
# fetch and process feed_url, apply tags...
end
end

Now the worker is a generic mechanism. The what (which feed, which tags) is data — not code.

Using It for Scheduled Jobs

The Oban cron plugin supports passing args directly to scheduled workers, so you get the same ergonomics for recurring jobs:

{Oban.Plugins.Cron,
crontab: [
{"0 * * * *", MyApp.Workers.FetchFeedWorker,
args: %{"feed_url" => "https://www.yellowduck.be/posts/feed",
"tags" => ["elixir", "phoenix"]}},
{"0 * * * *", MyApp.Workers.FetchFeedWorker,
args: %{"feed_url" => "https://changelog.com/podcast/feed",
"tags" => ["programming", "open-source"]}}
]}

Two cron entries, one worker module. Adding a third feed is a config change, not a code change.

Inserting One-Off Jobs

The same pattern works for manually enqueued jobs:

%{"feed_url" => "https://example.com/rss", "tags" => ["news"]}
|> MyApp.Workers.FetchFeedWorker.new()
|> Oban.insert()

Why This Matters

Less code to maintain. One module handles all feeds. Bug fixes and improvements apply everywhere automatically.

Clearer separation of concerns. The worker encodes how to process a feed. The job args encode which feed to process. These are genuinely different things and should live in different places.

More observable. Because args are stored in the database with each job, you can see exactly what configuration ran — useful when debugging why a particular job behaved a certain way.

Easier to extend. Want to add a max_items option? Add it to the args map and pattern match on it with a default. No new module required.

When to Keep Workers Specific

This pattern isn't always the right call. If two "similar" workers actually have meaningfully different logic — different parsing strategies, different retry behaviour, different side effects — a shared module can become a tangle of conditionals. In that case, separate modules with a shared private helper or a behaviour is often cleaner.

But when the logic is truly the same and only the inputs differ, push the inputs into args and let the worker be a function.