DI-Cluttering Your Code

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!

(Image courtesy of HIT Entertainment – modified by the author)

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

  1. Declare a private field to hold each dependency
  2. Declare each dependency as a parameter in the constructor, in order to have them injected for you
  3. 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.

Picture of John Waters

John Waters

As a highly respected and recommended technology leader, entrepreneur, and software architect extraordinaire, John Waters provides guidance for Trailhead teams and projects. His remarkable project requirements gathering and project estimation skills allow him to oversee teams that deliver client projects on time and on budget. John’s background includes over two decades of systems architecting, implementations for a range of high-performance business solutions, and speaking at conferences worldwide.

Free Consultation

Sign up for a FREE consultation with one of Trailhead's experts.

"*" indicates required fields

This field is for validation purposes and should be left unchanged.

Related Blog Posts

We hope you’ve found this to be helpful and are walking away with some new, useful insights. If you want to learn more, here are a couple of related articles that others also usually find to be interesting:

Our Gear Is Packed and We're Excited to Explore With You

Ready to come with us? 

Together, we can map your company’s software journey and start down the right trails. If you’re set to take the first step, simply fill out our contact form. We’ll be in touch quickly – and you’ll have a partner who is ready to help your company take the next step on its software journey. 

We can’t wait to hear from you! 

Main Contact

This field is for validation purposes and should be left unchanged.

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the form below. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Montage Portal

Montage Furniture Services provides furniture protection plans and claims processing services to a wide selection of furniture retailers and consumers.

Project Background

Montage was looking to build a new web portal for both Retailers and Consumers, which would integrate with Dynamics CRM and other legacy systems. The portal needed to be multi tenant and support branding and configuration for different Retailers. Trailhead architected the new Montage Platform, including the Portal and all of it’s back end integrations, did the UI/UX and then delivered the new system, along with enhancements to DevOps and processes.

Logistics

We’ve logged countless miles exploring the tech world. In doing so, we gained the experience that enables us to deliver your unique software and systems architecture needs. Our team of seasoned tech vets can provide you with:

Custom App and Software Development

We collaborate with you throughout the entire process because your customized tech should fit your needs, not just those of other clients.

Cloud and Mobile Applications

The modern world demands versatile technology, and this is exactly what your mobile and cloud-based apps will give you.

User Experience and Interface (UX/UI) Design

We want your end users to have optimal experiences with tech that is highly intuitive and responsive.

DevOps

This combination of Agile software development and IT operations provides you with high-quality software at reduced cost, time, and risk.

Trailhead stepped into a challenging project – building our new web architecture and redeveloping our portals at the same time the business was migrating from a legacy system to our new CRM solution. They were able to not only significantly improve our web development architecture but our development and deployment processes as well as the functionality and performance of our portals. The feedback from customers has been overwhelmingly positive. Trailhead has proven themselves to be a valuable partner.

– BOB DOERKSEN, Vice President of Technology Services
at Montage Furniture Services

Technologies Used

When you hit the trails, it is essential to bring appropriate gear. The same holds true for your digital technology needs. That’s why Trailhead builds custom solutions on trusted platforms like .NET, Angular, React, and Xamarin.

Expertise

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

  • Project Management
  • Architecture
  • Web App Development
  • Cloud Development
  • DevOps
  • Process Improvements
  • Legacy System Integration
  • UI Design
  • Manual QA
  • Back end/API/Database development

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

Our Gear Is Packed and We're Excited to Explore with You

Ready to come with us? 

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the contact form. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Thank you for reaching out.

You’ll be getting an email from our team shortly. If you need immediate assistance, please call (616) 371-1037.