👋Hi, I'm Waqas — a Software Architect and Technical Consultant specializing in .NET, Azure, microservices, and API-first system design..
I help companies build reliable, maintainable, and high-performance backend platforms that scale.
Dependency Injection in .NET Core: In-Depth with Code Examples
DI in .NET Core: lifetimes, registration, and best practices. Autofac and Scrutor.
November 2, 2024 · Waqas Ahmad
Read the article
Introduction
This guidance is relevant when the topic of this article applies to your system or design choices; it breaks down when constraints or context differ. I’ve applied it in real projects and refined the takeaways over time (as of 2026).
Tight coupling and new everywhere make code hard to test and change when you swap implementations or add new behaviour. This article explains DI in .NET Core: registration and injection, lifetimes (Singleton, Scoped, Transient), best practices, safe data access with injected services, and how to avoid captive and circular dependencies, with full code examples. For architects and tech leads, DI keeps code testable and loosely coupled so you can evolve and replace dependencies without rewrites.
System scale: Any .NET app with more than a handful of services or cross-cutting concerns; the built-in container is enough for most apps; Autofac/Scrutor when you need keyed services, decorators, or convention-based registration.
Team size: One to many teams; someone must own registration (composition root) and lifetime choices so you don’t get captive dependency or memory leaks.
Time / budget pressure: Fits greenfield and brownfield; breaks down when “we’ll just new it” is the norm and nobody wants to introduce interfaces—then start with constructor injection for new code only.
Technical constraints: .NET Core / .NET 5+ built-in DI; optional Autofac, Scrutor; assumes you can register services at startup (e.g. Program.cs, Extensions).
Non-goals: This article does not optimize for minimal abstractions, for “no interfaces,” or for non-.NET stacks; it optimises for testability and loose coupling in .NET.
What is dependency injection?
Dependency Injection is a technique where a class receives its dependencies (other services it needs) from the outside—typically via constructor, property, or method—instead of creating them itself with new. A DI container (e.g. the one in .NET Core) holds registrations (interface → implementation, lifetime) and resolves instances when a class is created. The class then depends on abstractions (interfaces), so you can swap implementations (e.g. real repository in production, mock in tests) without changing the class.
Benefits:Testability (inject mocks), loose coupling (depend on IOrderRepository, not OrderRepository), single place for configuration and lifetimes, cleaner constructors and separation of concerns.
Before and after: without DI vs with DI
Seeing the difference between without DI and with DI makes the value clear. Below: same use case (creating an order), first without DI, then with DI.
Before (without DI)
The controller creates its dependencies with new. It is tightly coupled to concrete types and hard to test (you cannot replace OrderRepository with a fake).
publicclassOrdersController : ControllerBase
{
privatereadonly OrderRepository _repo;
privatereadonly ILogger<OrdersController> _logger;
publicOrdersController()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer("Server=...;Database=App;...")
.Options;
_repo = new OrderRepository(new AppDbContext(options));
_logger = new LoggerFactory().CreateLogger<OrdersController>();
}
[HttpPost]
publicasync Task<IActionResult> Create(CreateOrderRequest request)
{
_logger.LogInformation("Creating order");
var order = await _repo.AddAsync(request);
return Ok(order);
}
}
Problems: Connection string and options are hardcoded; you cannot unit test without a real database; swapping OrderRepository for a mock requires changing the controller.
After (with DI)
The controller receives its dependencies via the constructor. The container provides the registered implementations. Testable (inject IOrderRepository mock) and loosely coupled (depends on IOrderRepository, not OrderRepository).
How this fits together: The container creates OrdersController when a request arrives; it sees the constructor needs IOrderRepository and ILogger<OrdersController>, resolves them (creating OrderRepository and the logger with the correct lifetime), and injects them. No new in the controller; configuration and lifetimes live in one place.
Injection types at a glance
Type
How
When to use
Constructor
Dependencies in constructor parameters; container resolves and injects.
Preferred for required dependencies.
Property
Public settable property; container or code sets it after construction.
Optional dependencies; rarely used in .NET Core.
Method
Method parameter(s); container or caller passes dependencies when calling the method.
When the dependency is only needed for one operation.
.NET Core and the built-in container are optimized for constructor injection. Use constructor injection for all required dependencies; use property or method injection only when necessary (e.g. optional or per-call dependency).
Constructor injection
What it is and when to use it
Constructor injection means the class declares its dependencies as constructor parameters. The container creates the class by resolving each parameter and passing the instances in. The class stores them in readonly fields and uses them for its lifetime. This is the primary and recommended way to inject dependencies in .NET Core.
When to use: For all required dependencies. The constructor makes dependencies explicit and the class immutable (dependencies do not change after construction). The container validates at startup that all dependencies can be resolved (when the first request tries to create the class).
Property injection means the container (or custom code) sets a property on the instance after construction. The built-in .NET Core container does not support property injection out of the box. Some third-party containers (e.g. Autofac) do. Use it only for optional dependencies when you cannot use constructor injection.
When to use: Rarely. Prefer constructor injection. If you need optional dependencies, consider constructor with default null and check before use, or use a factory.
Method injection
Method injection means the dependency is passed as a parameter to a method when it is called. The caller (or middleware) resolves the dependency and passes it in. Useful when the dependency is only needed for one operation or when it varies per call (e.g. HttpContext).
When to use: When the dependency is per-call or context-specific (e.g. current user, request-scoped data) and you do not want to hold it on the class. Often used with HttpContext or when the caller has the dependency already.
Lifetimes: Singleton, Scoped, Transient
Lifetime
Instance scope
Use when
Do not use when
Singleton
One instance for the entire application
Stateless: logger, config, cache client
Request-scoped or stateful (e.g. DbContext)
Scoped
One instance per scope (e.g. per HTTP request)
DbContext, repositories, per-request state
Injected into Singleton (captive dependency)
Transient
New instance every time it is requested
Lightweight, stateless: validators, mappers
When the same instance must be shared within a request
Singleton
Singleton is created once and reused for the lifetime of the application. Use for stateless services: ILogger<T>, IConfiguration, IDistributedCache (the client), IHttpClientFactory. Do not inject Scoped or Transient into a Singleton—that creates a captive dependency (the scoped instance is never disposed and can cause concurrency or stale data).
Scoped
Scoped is created once per scope. In ASP.NET Core, each HTTP request has a new scope, so DbContext and repositories are typically Scoped: one instance per request, disposed at the end of the request. Use for anything that holds request-specific state or uses DbContext.
Transient
Transient is created every time it is requested. Use for lightweight, stateless services: IValidator<T>, IMapper, small helpers. Do not use when the same instance must be shared within a request (e.g. a cache that should be per-request)—use Scoped instead.
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
// DbContext is registered as Scoped by default
Multiple implementations (choose by key or enumerate)
Option 1: Last registration wins (single interface, multiple implementations—container returns last registered unless you use a factory).
Option 2: Inject IEnumerable<T> to get all implementations:
Factory registration (create instance with custom logic)
builder.Services.AddSingleton<IEmailService>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
return config["Email:Provider"] == "SendGrid"
? new SendGridEmailService(config)
: new SmtpEmailService(config);
});
Options pattern (strongly typed configuration)
builder.Services.Configure<EmailOptions>(builder.Configuration.GetSection("Email"));
// Inject IOptions<EmailOptions>, IOptionsSnapshot<EmailOptions>, or IOptionsMonitor<EmailOptions>
Best practices
Program to interfaces – Depend on IOrderRepository, not OrderRepository. Enables testing and swapping implementations.
Prefer constructor injection – Required dependencies in the constructor; avoid property injection unless necessary.
Do not inject Scoped/Transient into Singleton – Causes captive dependency. Use IServiceScopeFactory in the Singleton to create a scope when you need a Scoped service.
Keep constructors simple – Many dependencies may indicate the class does too much; consider splitting.
Avoid service locator – Prefer constructor injection over IServiceProvider.GetRequiredService<T>() in application code. Use GetRequiredService only in composition root or factory code.
Register in one place – Use Program.cs or extension methods (e.g. AddOrderServices(this IServiceCollection services)) so all registrations are visible and testable.
Use IDbContextFactory<T> for background services – In Singleton (e.g. hosted service), do not inject DbContext directly; use IDbContextFactory<AppDbContext> and create a context per operation.
Safe data access with injected services
When you inject repositories, DbContext, or IDbConnection, your code runs SQL or calls stored procedures. To avoid SQL injection and manage data safely:
Parameterized queries (always)
Never concatenate user input into SQL. Always use parameters (e.g. EF Core Where, FromSqlRaw with parameters, or SqlParameter).
Before (unsafe – do not do this):
var sql = "SELECT * FROM Orders WHERE CustomerId = '" + request.CustomerId + "'";
var orders = await context.Orders.FromSqlRaw(sql).ToListAsync();
After (safe – parameterized):
var orders = await context.Orders
.Where(o => o.CustomerId == request.CustomerId)
.ToListAsync(ct);
// Or FromSqlRaw with parameters:var orders = await context.Orders
.FromSqlRaw("SELECT * FROM Orders WHERE CustomerId = @p0", request.CustomerId)
.ToListAsync(ct);
Stored procedures (parameterized)
When calling stored procedures, pass parameters explicitly; do not build the SQL string from user input.
var customerIdParam = new SqlParameter("@CustomerId", request.CustomerId);
var results = await context.Orders
.FromSqlRaw("EXEC dbo.GetOrdersByCustomer @CustomerId", customerIdParam)
.ToListAsync(ct);
Repository abstraction
Keep query building and parameterization inside the repository (or DbContext); the service layer receives only DTOs or domain objects. That way, all data access is centralized and safe.
How this fits together: The injectedIOrderRepository hides the data access; the service calls GetByCustomerAsync(customerId). The repository uses EF Core (parameterized) or ADO.NET with SqlParameter. Never pass raw SQL strings from the controller or service; keep SQL and parameters inside the repository or DbContext.
SQL injection simulation: table state and test values
A simulation shows what happens when you submit user input without parameterization vs with parameterization. Below: a small table, an array of test values (safe and malicious), and the effect on the table after “submit.”
Initial table state (e.g. Users)
Id
UserName
Role
1
alice
User
2
bob
User
3
admin
Admin
Test values array (inputs we “submit”)
Input
Intended use
Actually safe?
alice
Look up one user
Yes
bob
Look up one user
Yes
'; UPDATE Users SET Role='Admin' WHERE 1=1; --
Malicious
No – changes SQL meaning
'; DELETE FROM Users WHERE 1=1; --
Malicious
No – deletes all rows
1; DROP TABLE Users; --
Malicious
No – can drop table (if DB allows)
Unsafe “submit” (concatenated SQL)
Code builds: SELECT * FROM Users WHERE UserName = ' + input + '
For input alice: SQL = SELECT * FROM Users WHERE UserName = 'alice' → returns 1 row. Table unchanged. ✓
For input '; UPDATE Users SET Role='Admin' WHERE 1=1; --: SQL becomes SELECT * FROM Users WHERE UserName = ''; UPDATE Users SET Role='Admin' WHERE 1=1; --' → two statements run. The second updates every row.
Table after unsafe submit (malicious input)
Id
UserName
Role
1
alice
Admin
2
bob
Admin
3
admin
Admin
Effect: The whole table is affected; one malicious “submit” escalated every user to Admin. With DELETE FROM Users WHERE 1=1, all rows would be removed.
Safe “submit” (parameterized)
Code uses a parameter: SELECT * FROM Users WHERE UserName = @p0 with @p0 = input.
For any input (including '; UPDATE Users SET Role='Admin' WHERE 1=1; --), the value is treated as a single string for UserName. No row matches that literal string. No extra SQL runs. Table unchanged.
Table after safe submit (same malicious input)
Id
UserName
Role
1
alice
User
2
bob
User
3
admin
Admin
How this fits together: Without parameterization, one bad value in the “submit” can change the meaning of the SQL (extra statements, wrong rows, or destructive commands). With parameterization, the input is always one value; it cannot alter the structure of the query or affect other rows. Use this test values array (safe + malicious) in tests or demos to verify your repository never concatenates user input into SQL.
Audit trails and tracking changes
Audit trails record who did what, when (and optionally old/new values). They help you track changes, comply with regulations, and debug. When you use injected services for data access, you can add an injected audit service that writes to an audit table or audit log in a safe, parameterized way.
What to store (typical audit table or log)
Column / field
Purpose
Id
Unique audit entry id
Timestamp
When the change happened (UTC)
UserId / Actor
Who made the change (from auth context)
Action
Created, Updated, Deleted
EntityType
e.g. Order, User
EntityId
e.g. order id, user id
OldValue / NewValue
Optional JSON or columns for before/after (avoid logging passwords or secrets)
How this fits together: The audit store is injected like any other service; it uses the same DbContext (or a dedicated one) and parameterized writes. Every change can be recorded in one place. Do not build audit SQL from user input; use EF Core or parameters. Do not log sensitive data (passwords, tokens) in OldValue/NewValue; redact or omit.
Pivot-style queries, reporting, and logs
Pivot tables (or pivot-style queries) turn rows into columns (e.g. sales by month as columns). Reporting and query logs often involve dynamic column names or filters. Even there, never concatenate user input into SQL.
Scenario
Safe approach
Pivot / dynamic columns
Use whitelists: only allow a fixed set of column names (e.g. from config or enum). Build SQL from the whitelist, not from user input. For values (dates, ids), use parameters.
privatestaticreadonly HashSet<string> AllowedColumns = new(StringComparer.OrdinalIgnoreCase)
{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
publicasync Task<List<SalesRow>> GetSalesPivotAsync(string monthColumn, int year, CancellationToken ct = default)
{
if (!AllowedColumns.Contains(monthColumn))
thrownew ArgumentException("Invalid month column.", nameof(monthColumn));
// monthColumn is from whitelist; year is parameterizedreturnawait _context.SalesPivot
.FromSqlRaw("EXEC dbo.GetSalesPivot @MonthCol = {0}, @Year = {1}", monthColumn, year)
.ToListAsync(ct);
}
How this fits together:Pivot and reporting often need dynamic parts (e.g. which column). Use a whitelist for identifiers (column/table names); use parameters for values. Logs (audit and query) should be written through your injected services with parameterized or structured writes so nothing untrusted ends up in SQL or in log storage.
The default container in .NET Core is sufficient for most applications: Singleton, Scoped, Transient, IServiceProvider, IServiceScopeFactory, IOptions<T>, and factory registrations. Use it unless you need keyed services (before .NET 8), property injection, child containers, or advanced lifetime management.
Autofac
Autofac provides modules, keyed services, property injection, child scopes, and decorators. Use when you need enterprise features or modular registration.
Scrutor adds assembly scanning and decorator registration on top of the built-in container. Use to register many implementations by convention (e.g. all I*Handler in an assembly) or to wrap services with decorators (e.g. logging, caching) without manual registration of each decorator.
Do not inject Scoped or Transient into Singleton. Use IServiceScopeFactory in the Singleton and create a scope when you need a Scoped service; resolve from the scope and dispose the scope when done.
Circular dependency
A → B → A. Fix: Introduce an abstraction (e.g. both depend on IEventBus) or restructure so one direction is removed.
Wrong lifetime
Singleton for DbContext or request-specific state causes cross-request leakage. Use Scoped for DbContext and per-request state. Use Transient only for stateless, cheap-to-create services.
Service locator
Avoid GetRequiredService<T>() in application code. Use constructor injection so dependencies are explicit. Use GetRequiredService only in the composition root (e.g. Program.cs) or in factories.
SQL injection
Never concatenate user input into SQL. Use parameterized queries (EF Core, SqlParameter) or stored procedures with parameters. Keep SQL inside repositories.
Common pitfalls
Captive dependency – Singleton holding Scoped (e.g. DbContext). The Scoped instance is never disposed; concurrency and stale data. Fix: IServiceScopeFactory or make the consumer Scoped.
Wrong lifetime – Singleton for something that should be per-request (e.g. current user). Fix: Scoped for request-scoped state.
Circular dependency – A depends on B, B depends on A. Container cannot resolve. Fix: abstraction or restructure.
Overuse of service locator – GetRequiredService<T>() everywhere. Fix: constructor injection.
Registering concrete types only – Hard to mock. Fix: register interface → implementation.
SQL injection – Building SQL from user input. Fix: parameterized queries and stored procedures with parameters.
Summary
DI in .NET Core means registering services in the container and injecting them via constructors so code stays testable and loosely coupled. Skipping DI or misusing lifetimes (e.g. captive dependency) leads to hard-to-test code and runtime bugs; program to interfaces and avoid injecting Scoped into Singleton. Next, audit one service that still uses new for a dependency, introduce an interface and register it, then inject it and add a unit test.
Dependency Injection in .NET Core means registering services (interface → implementation, lifetime) and injecting them via constructors (or property/method when needed). Before DI, classes create dependencies with new (tight coupling, hard to test); after DI, the container provides dependencies (loose coupling, testable). Use constructor injection for required dependencies; Singleton, Scoped, and Transient for lifetimes. Best practices: program to interfaces, avoid captive dependency (do not inject Scoped into Singleton; use IServiceScopeFactory), avoid service locator in application code, keep data access safe (parameterized queries, stored procedures with parameters). The SQL injection simulation (table state, test values array, unsafe vs safe submit) shows how one malicious input can affect the whole table without parameterization. Audit trails (injected IAuditStore, who/when/what, parameterized writes) let you track changes; pivot-style queries and reporting use whitelists for column names and parameters for values; logs (audit and query) should be written safely. Enterprise options: built-in container (usually enough), Autofac (modules, keyed, property injection), Scrutor (scanning, decorators). This article covered what DI is, before/after, injection types, lifetimes, registration, best practices, safe data access, SQL injection simulation, audit trails, pivot/reporting/logs, enterprise frameworks, and how to avoid common pitfalls.
Position & Rationale
I use constructor injection by default; property and method injection only when the framework demands it (e.g. optional late-bound dependencies). I register interfaces to implementations in one place (composition root); I avoid Scoped or Singleton services that capture Transient or Scoped dependencies (captive dependency). I use the built-in container until I need keyed services, decorators, or convention-based registration—then Autofac or Scrutor. I avoid service locator and new for services that should be swappable; tests need mocks.
Trade-Offs & Failure Modes
What this sacrifices: More interfaces and indirection; onboarding takes longer until people internalise lifetimes. In return you get testability and loose coupling.
Where it degrades: When nobody owns the composition root and registrations scatter; or when Scoped is used inside Singleton (captive dependency, often bugs or memory leaks).
How it fails when misapplied: Registering everything as Singleton (shared state, threading issues); or the opposite—Transient for DbContext so each request gets a new one and you break unit-of-work. Another failure: service locator so tests can’t inject mocks.
Early warning signs: “We get ObjectDisposedException in production”; “our tests don’t use mocks”; “we have 50 services registered as Singleton.”
What Most Guides Miss
Most guides show registration and lifetimes but skip captive dependency: a Singleton that takes a Scoped or Transient dependency holds it for the app lifetime and can cause disposed-object or threading bugs. The other gap: where to register. Registration belongs in the composition root (e.g. API Program.cs or Extensions), not in libraries that “register themselves”—otherwise you get hidden coupling. Finally: Scoped in background services. A hosted service is Singleton; if it needs a DbContext, create a scope per operation (e.g. IServiceScopeFactory) and resolve Scoped inside that scope.
Decision Framework
If you need testability or swappable implementations → Use constructor injection and register interfaces in the composition root.
If the dependency is optional or late-bound → Consider method injection or IOptions; avoid service locator for core dependencies.
If you’re not sure about lifetime → Prefer Scoped for request-scoped resources (e.g. DbContext); Singleton for stateless utilities; Transient when each consumer must get a new instance.
If Singleton holds a dependency → That dependency must be Singleton or you have captive dependency; use IServiceScopeFactory in the Singleton to create a scope when you need Scoped.
If built-in container is not enough → Add Autofac (keyed, decorators) or Scrutor (convention-based assembly scan); don’t mix multiple containers for the same app.
You can also explore more patterns in the .NET Architecture resource page.
Key Takeaways
Constructor injection and interface registration in the composition root; avoid service locator and new for injectable services.
Match lifetime to usage: Scoped for request-scoped (e.g. DbContext), Singleton for stateless, Transient when each consumer needs a new instance.
Avoid captive dependency: Singleton must not hold Scoped or Transient; use IServiceScopeFactory in long-lived services when you need Scoped.
Register in one place (API startup); don’t let libraries self-register in a way that hides dependencies.
Use built-in container first; add Autofac or Scrutor when you need keyed services, decorators, or convention-based registration.
Need architectural guidance for real-world .NET platforms? I offer consulting for .NET architecture, API platforms, and enterprise system design.
When I Would Use This Again — and When I Wouldn’t
I would use DI again for any non-trivial .NET app where I want testability and loose coupling—greenfield or brownfield. I wouldn’t skip it to “move faster” if the app will grow; introducing interfaces later is costlier. I also wouldn’t use property or method injection for core dependencies when constructor injection works. Alternative: for tiny scripts or throwaway tools, new is fine; add DI when you add tests or multiple implementations.
Frequently Asked Questions
Frequently Asked Questions
What is dependency injection?
Dependency Injection is a technique where dependencies (e.g. repositories, services) are injected into a class (e.g. via constructor) instead of being created inside the class. A container holds registrations and creates instances when needed. This makes code testable (inject mocks) and keeps concerns separated.
What are DI lifetimes?
Singleton: one instance for the entire application. Scoped: one instance per scope (e.g. per HTTP request). Transient: a new instance every time it is requested. Choose based on whether the service is stateless and app-wide (Singleton), request-scoped (Scoped), or lightweight and not shared (Transient).
What is captive dependency?
A captive dependency is when a long-lived service (e.g. Singleton) holds a reference to a short-lived service (e.g. Scoped DbContext). The short-lived instance is never disposed correctly and can cause concurrency or stale data. Fix: do not inject Scoped into Singleton; use IServiceScopeFactory to create a scope when the Singleton needs a Scoped service.
When should I use Singleton?
For stateless services: logger, config, cache client. Services that are expensive to create and safe to share. Do not use for DbContext or request-specific state.
When should I use Scoped?
For per-request services: DbContext, repositories, anything with request-specific state. Disposed at end of request.
When should I use Transient?
For lightweight, stateless services like validators, mappers. New instance each time, not shared.
How do I inject into controllers?
Constructor injection. Declare interfaces in the constructor; the container provides the registered implementations when the controller is created.
How do I resolve services manually?
Inject IServiceProvider or IServiceScopeFactory. Use GetRequiredService<T>() or GetService<T>(). For Scoped services, create a scope with CreateScope(), resolve from the scope, and dispose the scope when done.
How do I avoid captive dependency?
Do not inject Scoped (or Transient used as scoped) into Singleton. Use IServiceScopeFactory in the Singleton: create a scope, resolve the Scoped service from the scope, use it, then dispose the scope.
What is circular dependency?
A depends on B and B depends on A. The container cannot resolve. Fix: introduce an abstraction (e.g. both depend on a third interface) or restructure so one direction is removed.
How do I test with DI?
Register mocks (e.g. Mock) in a test ServiceCollection, build a ServiceProvider, and resolve the class under test. Or use WebApplicationFactory for integration tests.
Can I use third-party containers?
Yes. Autofac, Ninject, etc. The built-in container is usually enough. Use Autofac for modules, keyed services, or property injection; use Scrutor for assembly scanning and decorators.
How do I register multiple implementations?
Register each implementation for the same interface. Inject IEnumerable<IYourInterface> to get all, or use keyed services (.NET 8+: AddKeyedScoped, GetRequiredKeyedService<T>(key)) to resolve by key.
What is IServiceScopeFactory?
A factory that creates scopes. Use it in Singleton or long-lived code when you need to resolve Scoped services (e.g. DbContext). Create a scope, resolve from it, use, then dispose the scope.
How do I avoid SQL injection when using injected repositories?
Never concatenate user input into SQL. Use parameterized queries (EF Core Where, FromSqlRaw with parameters, or SqlParameter). Use stored procedures with parameters. Keep all SQL and parameters inside the repository or DbContext; the service layer receives only DTOs or domain objects.
What are keyed services?
.NET 8+ feature: register multiple implementations of the same interface under different keys. Resolve with GetRequiredKeyedService<IEmailService>("smtp"). Use when you need to choose implementation by configuration or context (e.g. “smtp” vs “sendgrid”).
What is the SQL injection simulation (table state, test values)?
A simulation uses a small table (e.g. Users), an array of test values (safe inputs like alice and malicious inputs like '; UPDATE Users SET Role='Admin' WHERE 1=1; --), and shows before/after table state. Unsafe submit (concatenated SQL): malicious input can run extra SQL and affect the whole table (e.g. all rows become Admin). Safe submit (parameterized): the same input is treated as one string; no extra SQL runs; table unchanged. Use this to verify your repo never concatenates user input into SQL.
How do I add audit trails with DI?
Inject an IAuditStore (or similar) that writes to an audit table or log. Store who (UserId), when (TimestampUtc), what (Action, EntityType, EntityId), and optionally old/new value (sanitized; never log passwords). Use parameterized inserts (EF Core or SqlParameter). Call _audit.AppendAsync(entry) after each change in your service. Register IAuditStore as Scoped (same scope as DbContext).
Pivot tables, reporting, and logs – safe with DI?
Pivot-style and reporting queries: use a whitelist for column/table names (never from user input); use parameters for filter values (dates, ids). Query logs: log parameterized SQL and parameter values separately; do not log raw concatenated SQL with user input. Audit logs: use an injected audit service with parameterized writes; do not build log text from unsanitized input.
Related Guides & Resources
Explore the matching guide, related services, and more articles.