This blog is presented as a part of C# Advent 2024. Follow the link to check out the rest of the excellent C# and .NET content coming out as 2 blogs per day between December 1 - 25.
To put it simply, this article focuses on reducing the clutter that Dependency Injection (DI) can bring to your code—a powerful tool for clean architecture that, if misused, can also lead to monstrosities like this:
public class ClaimController : RORepoController<Claim, IClaimRepo, ClaimDetailDTO, ClaimGetAllDTO, ClaimSearchDTO>
{
private readonly ILogger<ClaimController> _logger;
private readonly IConsumerPlanViewRepo _consumerPlanViewRepo;
private readonly IConsumerViewRepo _consumerRepo;
private readonly IProductIncidentViewRepo _productIncidentViewRepo;
private readonly ICoveredProductViewRepo _coveredProductViewRepo;
private readonly IRetailerViewRepo _retailerViewRepo;
private readonly IClaimNotesViewRepo _claimNotesViewRepo;
private readonly IServiceActionViewRepo _serviceActionViewRepo;
private readonly ICommunicationViewRepo _communicationViewRepo;
private readonly IRegionRepo _regionRepo;
private readonly IFileBoundHelper _fileBoundHelper;
private readonly IFileApiCaller _fileApiCaller;
private readonly FileApiOptions _fileApiOptions;
private readonly IBackgroundTaskQueue _taskQueue;
private readonly IPartOrderViewRepo _partOrderViewRepo;
private readonly IUtilityHelper _utilityHelper;
private readonly ITokenHelper _tokenHelper;
private readonly IClaimViewRepo _claimViewRepo;
private readonly IDocTokenHelper _docTokenHelper;
private readonly ICrmHelper _crmHelper;
private readonly IServiceActionProductViewRepo _serviceActionProductViewRepo;
private readonly IOtherClaimViewRepo _otherClaimViewRepo;
private readonly IProductIncidentClaimViewRepo _picRepo;
private readonly ISessionCacheHelper _sessionCacheHelper;
private readonly IPortalClaimNoteViewRepo _portalClaimNoteViewRepo;
private readonly IClaimNoteHelper _claimNoteHelper;
private readonly IQHelper _qHelper;
private readonly ITenantRepo _tenantRepo;
private readonly SystemOptions _systemOptions;
public ClaimController(
IUserContext userContext,
IMapper mapper,
IExcelWriter excelWriter,
ILogger<ClaimController> logger,
IQHelper qHelper,
IClaimRepo repo,
IProductIncidentClaimViewRepo picRepo,
ISessionCacheHelper sessionCacheHelper,
IPortalClaimNoteViewRepo portalClaimNoteViewRepo,
IClaimNoteHelper claimNoteHelper,
IConsumerPlanViewRepo consumerPlanViewRepo,
IConsumerViewRepo consumerRepo,
IProductIncidentViewRepo productIncidentViewRepo,
ICoveredProductViewRepo coveredProductViewRepo,
IRetailerViewRepo retailerViewRepo,
IClaimNotesViewRepo claimNotesViewRepo,
IServiceActionViewRepo serviceActionViewRepo,
ICommunicationViewRepo communicationViewRepo,
IRegionRepo regionRepo,
IFileBoundHelper fileBoundHelper,
IFileApiCaller fileApiCaller,
IOptions<FileApiOptions> fileApiOptions,
IOtherClaimViewRepo otherClaimViewRepo,
IBackgroundTaskQueue taskQueue,
IPartOrderViewRepo partOrderViewRepo,
IUtilityHelper utilityHelper,
ITokenHelper tokenHelper,
IClaimViewRepo claimViewRepo,
IDocTokenHelper docTokenHelper,
ICrmHelper crmHelper,
IServiceActionProductViewRepo serviceActionProductViewRepo,
ITenantRepo tenantRepo,
IOptions<SystemOptions> systemOptions
) : base(userContext, mapper, repo, excelWriter)
{
_logger = logger;
_systemOptions = systemOptions.Value;
_consumerPlanViewRepo = consumerPlanViewRepo;
_consumerRepo = consumerRepo;
_productIncidentViewRepo = productIncidentViewRepo;
_coveredProductViewRepo = coveredProductViewRepo;
_picRepo = picRepo;
_sessionCacheHelper = sessionCacheHelper;
_portalClaimNoteViewRepo = portalClaimNoteViewRepo;
_claimNoteHelper = claimNoteHelper;
_retailerViewRepo = retailerViewRepo;
_claimNotesViewRepo = claimNotesViewRepo;
_serviceActionViewRepo = serviceActionViewRepo;
_communicationViewRepo = communicationViewRepo;
_regionRepo = regionRepo;
_fileBoundHelper = fileBoundHelper;
_fileApiCaller = fileApiCaller;
_fileApiOptions = fileApiOptions.Value;
_taskQueue = taskQueue;
_partOrderViewRepo = partOrderViewRepo;
_utilityHelper = utilityHelper;
_tokenHelper = tokenHelper;
_claimViewRepo = claimViewRepo;
_docTokenHelper = docTokenHelper;
_crmHelper = crmHelper;
_serviceActionProductViewRepo = serviceActionProductViewRepo;
_otherClaimViewRepo = otherClaimViewRepo;
_qHelper = qHelper;
_tenantRepo = tenantRepo;
}
I mean… we’ve all seen it right? In this article I first talk a bit about how we got here, and then how we get out of it!
The Back Story
Our code didn’t start this way, it had just a few dependencies that were needed – an ILogger for logging, an options class for getting some configuration, and a repository. But then a second repository was needed, and also a helper to call a file service – and then another to queue a message… As time passed, more and more methods were added to the controller, and as we are using constructor DI, each of these now got added to the constructor and became a private field.
Now – no single API method needs all of these dependencies, but as the controller got fatter and fatter (like this character!), so did the list of things to inject – which sounds like a recipe for Type 2 Diabetes!

Obviously there are some major flaws with writing code this way, ranging from readability, maintainability (it’s very easy to break something with all this commingled but possibly unrelated code), performance (loading all this into memory, possibly establishing all kinds of connections to services that may or may not be needed), and so on.
A Simple Solution
One of the simplest things to do here is refactor – both horizontally and vertically. Maybe the API needs more controllers, with smaller areas of responsibility? Perhaps this ClaimController is doing a lot more than it needs to? But also, maybe we can use a layer or two of services to handle some of the business logic, and not have it all stuffed in the controller? These business-logic oriented services come with many names, and the layers can be arranged in many ways, including the popular Clean Architecture. Sometimes they are called handlers, sometimes services. Either way, they orchestrate the work of taking input from the caller, coordinating some transactional or query work across a set of infrastructure, and returning a result.
New Architectures to the Rescue
Aside from reorganizing and stratifying these ‘fat controllers’, as described above, in the old ApiController-based architecture, some nice new ways of building single-responsibility APIs have emerged in the last few years. One of my favorites, Minimal APIs, has made it much easier to create single-purpose API endpoints, that don’t create the almost automatic bloat that ApiControllers create when you start reusing and aggregating functionality in one class. Early on in adoption, this can lead to all of the code bloat moving to MapXXX() calls in Program.cs instead, but there is no need to do that. Minimal API endpoints can be collected in any way you see fit – at Trailhead, for instance, we organize them into what we call modules, and then have a fluent interface that allows the endpoints to be constructed by convention and reflection, providing easy access to things like Swagger/OpenAPI documentation, validation and authorization:
public class CameraImageModule : IModule
{
/// <inheritdoc />
public void BuildEndpoints(EndpointBuilder with) =>
with
.RequiredAuthorizationForAll(Feature.ViewFleetStatus)
.GetBy<GetCameraImageHandler>("{imageId:int}", "Fetch a camera verification image")
.HasChildEntity<ImageVerification>(add => add
.CreateBy<CameraImageRejectionHandler>("rejection", "Reject a camera image", Feature.AcceptRejectVerificationImage)
.CreateBy<CameraImageAcceptanceHandler>("acceptance", "Accept a camera image", Feature.AcceptRejectVerificationImage)
.CreateBy<CameraImageSetAsReferenceHandler>("set-as-reference", "Set a camera image as the reference image", Feature.SetReferenceImage));
}
MediatR has also been a great way to decouple the endpoint from the handler, removing direct dependencies between the two and marrying them back up by matching the inputs and outputs in runtime. The framework above doesn’t use MediatR, and you can see we directly specify our handlers right in the endpoint definition, but that’s just because MediatR introduces more decoupling than we actually need in this project.
Here is another module:
public class DeviceModule : IModule
{
/// <inheritdoc />
public void BuildEndpoints(EndpointBuilder with) =>
with
.DefaultAuthorizationForAll(Feature.ViewFleetStatus)
.GetBy<GetDeviceHandler>()
.GetBy<GetFirmwareCountsHandler>("firmware-revisions", "Fetch all firmware revisions")
.GetBy<GetDeviceTypesHandler>("types", "Fetch all type names")
.GetBy<GetActiveDevicesTypeCheckHandler>("active-devices-same-type")
.SearchBy<SearchDatabaseDeviceHandler>("database-devices/all", auth: Feature.ViewFleetDatabase)
.SearchBy<SearchDeviceHandler>()
)
.CreateBy<CreateDatabaseDeviceHandler>("database-device", auth: Feature.AddVehicleToFleet)
.UpdateBy<UpdateDatabaseDeviceHandler>("/database-device", auth: Feature.AddVehicleToFleet)
.DeleteBy<DeleteByIdHandler<Device>>(auth: Feature.RemoveVehicleFromFleet);
}
In this module, some endpoints are implemented by completely generic handlers, for instance the DeleteByIdHandler:
public class DeleteByIdHandler<TEntity> : IApplicationRequestHandlerDelete
where TEntity : class, IEntity<Identifier>, new()
{
protected readonly ICommandService<TEntity> CommandService;
public DeleteByIdHandler(ICommandService<TEntity> commandService)
{
CommandService = commandService;
}
public virtual Task<Result<bool>> Handle(Identifier id, CancellationToken ct)
=> CommandService.DeleteAsync(id, ct);
}
This handler in turn just delegates its work to a generic command service, which knows how to do generic commands in an Entity Framework repository, like deleting, updating, and dreating, and of course, there is also a QueryService that does generic queries, like getting by ID, listing, searching, etc.
Looking at some other handlers, we can see that they are now much slimmer, and don’t suffer from this huge laundry list of injected dependencies used for all the other, now non-coupled handlers:
public class CreateDatabaseDeviceHandler(
ICommandService<Device> commandService,
IServerConfigService serverConfigService) : IApplicationRequestHandler<DatabaseDeviceDTO, bool>
{
public async Task<Result<bool>> Handle(DatabaseDeviceDTO command, CancellationToken ct)
{
var licenseResult = await serverConfigService.HasLicenseForAsync(LicensedFeature.CELLULAR, ct);
if (licenseResult.IsFaulted && command.IsCellular)
{
return licenseResult.Failure!;
}
var device = DeviceBehavior.MapDatabaseDevice(command.Name.Trim(), command.SerialNumber?.Trim(),
command.DeviceType?.Trim(), command.ConnectionAddress.Trim(),
command.ConnectionPort, command.Description?.Trim(), command.CellularAddress?.Trim(), command.CellularPort);
var result = await commandService.CreateAsync<DatabaseDeviceDTO>(device, ct);
return result.IsFaulted ? result.Failure! : Success(true);
}
}
Even though the DeviceModule has 19 API endpoints, each one is now either served by a generic handler for basic CRUD, or a specialized handler for the business logic for non-trivial endpoints. The end result is a lot easer to read and maintain.
Primary Constructors
The next allies in your fight against DI bloat are primary constructors, introduced in C# 12. Primary constructors allow you to avoid a bunch of boilerplate noise traditionally used when injecting dependencies., Previously, you would need to
- Declare a private field to hold each dependency
- Declare each dependency as a parameter in the constructor, in order to have them injected for you
- Write code in the constructor to copy the parameter value to your field
This might look like this:
public class GetStatusHistoryHandler :
IApplicationRequestHandlerChildByLongId<DeviceStatusDetailDTO>
{
private readonly IRoEntityRepo<DeviceStatus> deviceStatusRepo;
private readonly IRoEntityRepo<Device> deviceRepo;
private readonly IDataAuthorizationService authorizationService;
private readonly ISerializationService serializationService;
private readonly IMapper mapper;
public GetStatusHistoryHandler(
IRoEntityRepo<DeviceStatus> deviceStatusRepo,
IRoEntityRepo<Device> deviceRepo,
IDataAuthorizationService authorizationService,
ISerializationService serializationService,
IMapper mapper)
{
this.deviceStatusRepo = deviceStatusRepo;
this.deviceRepo = deviceRepo;
this.authorizationService = authorizationService;
this.serializationService = serializationService;
this.mapper = mapper;
}
public async Task<Result<DeviceStatusDetailDTO>> Handle(Identifier id, Identifier parentId, CancellationToken ct)
{
var deviceExists = await deviceRepo.GetQueryableById(parentId, noTracking: true).AnyAsync(ct);
...
Or this, with the_ field name convention
public class GetStatusHistoryHandler :
IApplicationRequestHandlerChildByLongId<DeviceStatusDetailDTO>
{
private readonly IRoEntityRepo<DeviceStatus> _deviceStatusRepo;
private readonly IRoEntityRepo<Device> _deviceRepo;
private readonly IDataAuthorizationService _authorizationService;
private readonly ISerializationService _serializationService;
private readonly IMapper _mapper;
public GetStatusHistoryHandler(
IRoEntityRepo<DeviceStatus> deviceStatusRepo,
IRoEntityRepo<Device> deviceRepo,
IDataAuthorizationService authorizationService,
ISerializationService serializationService,
IMapper mapper)
{
_deviceStatusRepo = deviceStatusRepo;
_deviceRepo = deviceRepo;
_authorizationService = authorizationService;
_serializationService = serializationService;
_mapper = mapper;
}
public async Task<Result<DeviceStatusDetailDTO>> Handle(Identifier id, Identifier parentId, CancellationToken ct)
{
var deviceExists = await _deviceRepo.GetQueryableById(parentId, noTracking: true).AnyAsync(ct);
...
With primary constructors, this now looks like this:
public class GetStatusHistoryHandler(
IRoEntityRepo<DeviceStatus> deviceStatusRepo,
IRoEntityRepo<Device> deviceRepo,
IDataAuthorizationService authorizationService,
ISerializationService serializationService,
IMapper mapper) :
IApplicationRequestHandlerChildByLongId<DeviceStatusDetailDTO>
{
public async Task<Result<DeviceStatusDetailDTO>> Handle(Identifier id, Identifier parentId, CancellationToken ct)
{
var deviceExists = await deviceRepo.GetQueryableById(parentId, noTracking: true).AnyAsync(ct);
...
Gone is all that bloat, all those backing fields… you can now clearly read the actual code of the Handler!
Wrapping up
To summarize, you can easily eliminate DI bloat and “declutter” your code by using Minimal APIs instead of ApiControllers, moving business logic from controllers to single-responsibility services or handlers, and leveraging primary constructors to streamline the remaining complexity.
If you’re ready to transform your codebase and tackle DI clutter—or take your software architecture to the next level—reach out to Trailhead! Let’s build something beautifully efficient together.


