Skip to content

Dependency Injection

Repl Toolkit uses Microsoft.Extensions.DependencyInjection through ReplApp (the Repl.Defaults package). Repl.Core (CoreReplApp) has no DI container — services are passed explicitly.

var app = ReplApp.Create(services =>
{
services.AddSingleton<IContactStore, InMemoryContactStore>();
services.AddSingleton(typeof(IStore<>), typeof(InMemoryStore<>)); // open generic
services.AddScoped<ICurrentUser, SessionUser>(); // per-session
}).UseDefaultInteractive();

Handler parameters that match registered services are injected automatically. No attribute is needed:

app.Map("list", (IContactStore store) => store.All());
app.Map("add {name} {email:email}",
(string name, string email, IContactStore store) =>
{
store.Add(name, email);
return Results.Success($"Added {name}.");
});

Repl resolves parameters in a specific order — route values, then options, then DI. If a parameter name matches a route segment, it gets the route value. Otherwise Repl tries DI.

Use [FromServices] to force DI resolution when disambiguation is needed:

app.Map("show {id}", ([FromServices] IContactStore store, int id) => store.Get(id));

Route values from parent contexts are available in child scopes via [FromContext]:

app.Context("project {id:int}", project =>
{
project.Map("status",
([FromContext] int id, IProjectService svc) => svc.GetStatus(id));
project.Map("archive",
([FromContext] int id, IProjectService svc) =>
{
svc.Archive(id);
return Results.Success("Archived.");
});
});

[FromContext] binds by parameter name matching the route segment name.

Use static lambdas to avoid accidental closures. All services should be injected, not captured:

// Good — static, services injected
app.Map("list", static (IContactStore store) => store.All());
// Avoid — captures 'store' from outer scope
var store = new ContactStore();
app.Map("list", () => store.All());

Static lambdas prevent accidental closures and make handlers independently testable.

LifetimeScope in Repl
SingletonShared across all sessions and all commands
ScopedOne instance per session in hosted sessions; in CLI mode there is no scope boundary between invocations — Scoped behaves like Transient
TransientNew instance per command handler invocation

Use Scoped for per-user state in hosted sessions (shopping cart, authentication context, cursor position). Use Singleton for shared data stores.

Global options are flags available on every command invocation, parsed before the route:

app.Options(o =>
{
o.Parsing.AddGlobalOption<string>("tenant");
o.Parsing.AddGlobalOption<bool>("verbose");
});

Access them in DI factory registrations:

builder.Services.AddSingleton<ITenantClient>(sp =>
{
var globals = sp.GetRequiredService<IGlobalOptionsAccessor>();
return new TenantClient(globals.GetValue<string>("tenant", "default")!);
});

For many global options, use a typed class:

app.UseGlobalOptions<MyGlobalOptions>();
// then inject anywhere
app.Map("info", (MyGlobalOptions opts) => opts);

Note: Singleton factories are resolved once. If a global option can change between REPL commands (e.g. --tenant can be toggled interactively), inject IGlobalOptionsAccessor directly and read at call time rather than in the factory.

IReplModule implementations are resolved from DI:

builder.Services.AddSingleton<AuditModule>();
// ...
app.MapModule<AuditModule>();

This lets modules declare their own dependencies in constructors — the same way any other DI service does.