Runly jobs support the dependency injection (DI) software design pattern, which is a technique for achieving Inversion of Control (IoC) between classes and their dependencies.

There are two ways for dependencies to be injected into a Runly job: via the constructor or via the ProcessAsync method.

Constructor Injection

A constructor dependency is simply added to the constructor parameters so that when the job is instantiated the dependencies are provided. These dependencies can be saved as local variables to be used in InitializeAsync and FinalizeAsync. For example, if InitializeAsync needs an IDbConnection to execute a database query, IDbConnection should be added to the constructor. Unless these dependencies are thread-safe, they should generally not be used in ProcessAsync. The second method of dependency injection should be used for dependencies that ProcessAsync requires.

Example

This example job takes two dependencies in the constructor: IDatabase and ILogger<MyJob>. It then uses those dependencies in InitializeAsync and GetItemsAsync.

public class MyJob : Job<MyConfig, string>
{
	readonly IDatabase db;
	readonly ILogger<MyJob> logger;

	public MyJob(MyConfig cfg, IDatabase db, ILogger<MyJob> logger)
		: base(cfg)
	{
		this.db = db;
		this.logger = logger;
	}

	public override async Task InitializeAsync()
	{
		logger.LogDebug("Doing something in the database");
		await db.DoSomething();
	}

	public override IAsyncEnumerable<string> GetItemsAsync()
	{
		return db.GetSomeItemsFromTheDatabase().ToAsyncEnumerable();
	}
}

Method Injection

The ProcessAsync method will be called in parallel unless Options.CanProcessInParallel == false or Config.Execution.ParallelTaskCount == 1. Because of this, dependencies used in ProcessAsync that are not thread-safe, such as a database connection, should be taken through the ProcessAsync method and not the constructor. A dependency taken through the constructor would be shared across multiple parallel executions of ProcessAsync, likely leading to hard to diagnose errors. When ProcessAsync is called, each dependency is retrieved from a scoped dependency injection container. In this case, the scope is one of many Tasks which is calling ProcessAsync. As a best practice, ProcessAsync should only use dependencies passed in via the method itself.

ProcessAsync can accept between zero to sixteen dependencies in addition to TItem, depending on which Job base class is extended. In addition to Job<TConfig, TItem> there is Job<TConfig, TItem, T1>, Job<TConfig, TItem, T1, T2> and so on, up to Job<TConfig, TItem, T1, T2,…T16>. These extra generic type parameters correspond to the ProcessAsync definition in the class. For Job<TConfig, TItem, T1>, ProcessAsync is defined as ProcessAsync(TItem item, T1 arg1). This allows strongly typed dependencies to be added to ProcessAsync simply by adding generic type parameters to the base class being extended.

Example

Building on the previous example, ProcessAsync takes its own dependency on IDatabase so that it will get a new instance of IDatabase for each parallel task execution.

public class MyJob : Job<MyConfig, string, IDatabase>
{
	readonly IDatabase db;
	readonly ILogger<MyJob> logger;

	public MyJob(MyConfig cfg, IDatabase db, ILogger<MyJob> logger)
		: base(cfg)
	{
		this.db = db;
		this.logger = logger;
	}

	public override async Task InitializeAsync()
	{
		logger.LogDebug("Doing something in the database");
		await db.DoSomething();
	}

	public override IAsyncEnumerable<string> GetItemsAsync()
	{
		return await db.GetSomeItemsFromTheDatabase().ToAsyncEnumerable();
	}

	public override async Task<Result> ProcessAsync(string item, IDatabase db)
	{
		// do some super intensive processing of some kind

		// this is the method's instance of db
		await db.SaveThing(result);

		// ILogger is thread-safe, so we can use the class variable here
		logger.LogDebug("Saved the thing");

		return Result.Success();
	}
}

Registering Dependencies

Runly jobs use the built-in service container, IServiceProvider, by default.

A common pattern for registering dependencies in your app is to create an extension method to register dependencies for a job or group of jobs:

public static class ServiceExtensions
{
	public static IServiceCollection AddMyJobDependencies(this IServiceCollection services)
	{
		return services.AddScoped<IDatabase, MyDatabase>();
	}
}

class Program
{
	static async Task Main(string[] args)
	{
		await JobHost.CreateDefaultBuilder(args)
			.ConfigureServices((host, services) =>
			{
				services.AddMyJobDependencies();
			})
			.Build()
			.RunJobAsync();
	}
}

This example registers the IDatabase dependency that MyJob needs. Note that the jobs within the application (e.g. MyJob) and config types (e.g. MyConfig) are automatically registered for you.

Using Job Configuration to Register Dependencies

A real database implementation would require a connection string to a database. In the case of MyJob, we can amend MyConfig to include a connection string:

public class MyConfig : Config
{
	public string ConnectionString { get; set; }
}

Then, when registering MyDatabase, we can reference the config to create an instance of MyDatabase:

services.AddScoped<IDatabase>(s =>
{
	var config = s.GetRequiredService<MyConfig>();
	return new MyDatabase(config.ConnectionString);
});

This pattern of using the config when registering dependencies is common enough that Runly provides some convenient helper extension methods to streamline this process. The following example is equivalent to above:

services.AddScoped<IDatabase, MyConfig>((s, config) =>
	new MyDatabase(config.ConnectionString)
);

To make this work, the JobHost default builder auto-registers the config passed-in to the job app as a singleton. It is the config for the job that is about to execute and can be resolved using the concrete type, MyConfig or the base Config type.

Lifetime Considerations for Parallel Tasks

As mentioned previously, since the ProcessAsync method can be called in parallel, you should take non-thread-safe dependencies in the ProcessAsync method rather than the constructor. However, in order for this to fully work, you have to make sure to register your non-thread-safe dependencies using the correct lifetime scope. The built-in IServiceProvider includes three lifetime scopes:

Transient

Transient lifetime services (AddTransient) are created each time they’re requested from the service container. This lifetime works best for lightweight, stateless services. Every call to ProcessAsync with one of these services will use a different instance.

Scoped

Scoped lifetime services (AddScoped) are created once per parallel task. This is the scope you should use if you have a heavier, non-thread-safe dependency in ProcessAsync.

Singleton

Singleton lifetime services (AddSingleton) are created the first time they’re requested and return the same instance each time they’re requested from the service container. You shouldn’t register a dependency as a singleton if it is to be used in ProcessAsync. Every call to ProcessAsync with one of these services will use the same instance. In that case, you can just take the dependency in the constructor and share it across calls to ProcessAsync.