Domain-Driven Refactoring: Extracting Domain Services
Posts in this series:
- Intro
- Procedural Beginnings
- Long Methods
- Extracting Domain Services
- Defactoring and Pushing Behavior Down
- Encapsulating Data
- Encapsulating Collections
In my last post, we looked at the Compose Method refactoring as a means of breaking up long methods into smaller ones, each with an equivalent level of granularity. This is the refactoring in my applications I tend to use the most, mainly because it's the simplest way of breaking up a hard-to-understand method.
However, this series is about Domain-Driven Design, not just plain refactorings, so what's the difference here? With domain-driven refactoring, we're trying to refactor towards a domain-driven design, which defines model building blocks of:
- Entities
- Aggregates
- Services
- Factories
- and more
These patterns aren't new, nor is refactoring to these models. In fact it's explicitly called out in the book in the "Refactoring towards deeper insight" section, with the idea that we don't start with a domain model, but rather we arrive at it through refactoring.
We last left off merely extracting methods, which is fine for procedural code, but still left a bit to be desired especially with testability. This method in particular is problematic, which uses an external web API to calculate the value of an offer:
private async Task<int> CalculateOfferValue(Member member, OfferType offerType,
CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync(
$"/calculate-offer-value?email={member.Email}&offerType={offerType.Name}",
cancellationToken);
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
var value = await JsonSerializer.DeserializeAsync<int>(
responseStream,
cancellationToken: cancellationToken);
return value;
}
Now a lot of folks I know would never have allowed this code to exist as-is in the first place, immediately encapsulating this web API in some kind of service from the outset. But that's no fun! Let's instead use refactoring techniques (assisted by ReSharper) to:
And a couple others to pull that method out into something else.
Extracting the Class
One of the immediate challenges we'll have moving this method is that it contains a reference to a private field. If we extract the class as-is, we'll need to make sure any private fields are also moved. Some tooling can't do all this work for us, but luckily, we're using ReSharper! I put my caret on the method I want to extract and select the "Extract Class" refactoring to have this dialog pop up:
There are a few errors I have to fill out, the class name, the private field, and visibility of the existing method (which is private). For a name, I tend to use the name of the method as a guide. If the name of the method is CalculateOfferValue
, then the name of the class would represent this responsibility, OfferValueCalculator
. Next, I need to fix that private field. The simple fix is to click that "Extract" link, which will extract the field into a private field in the target class. Finally, I can fix the visibility in the target class member by making it public.
Here's the dialog result after making those choices:
And we also see that it's filled in the "Reference to extracted" above. Finally, when we perform the refactoring, our class is extracted:
public class OfferValueCalculator
{
private readonly HttpClient _httpClient;
public OfferValueCalculator(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<int> CalculateOfferValue(Member member,
OfferType offerType,
CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync(
$"/calculate-offer-value?email={member.Email}&offerType={offerType.Name}",
cancellationToken);
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content
.ReadAsStreamAsync(cancellationToken);
var value = await JsonSerializer.DeserializeAsync<int>(
responseStream,
cancellationToken: cancellationToken);
return value;
}
}
And our constructor in the handler now uses this new class:
public class AssignOfferHandler : IRequestHandler<AssignOfferRequest>
{
private readonly AppDbContext _appDbContext;
private readonly OfferValueCalculator _offerValueCalculator;
public AssignOfferHandler(
AppDbContext appDbContext,
HttpClient httpClient)
{
_appDbContext = appDbContext;
_offerValueCalculator = new OfferValueCalculator(httpClient);
}
And finally our usage uses this new extracted class:
public async Task<Unit> Handle(AssignOfferRequest request, CancellationToken cancellationToken)
{
var member = await _appDbContext.Members.FindAsync(request.MemberId, cancellationToken);
var offerType = await _appDbContext.OfferTypes.FindAsync(request.OfferTypeId, cancellationToken);
// Calculate offer value
var value = await _offerValueCalculator.CalculateOfferValue(member, offerType, cancellationToken);
So far so good! But we're not quite done - our handler class still directly instantiates/uses this concrete class, making unit testing difficult.
Extracting the interface
To get to a testable point, we need to first extract an interface for that OfferValueCalculator
. First, we'll put our caret in that class and select the Extract Interface refactoring:
We can leave these defaults alone, and we only want that lone method in our new interface. This refactoring creates the new interface and makes our class implement that interface:
public interface IOfferValueCalculator
{
Task<int> CalculateOfferValue(Member member,
OfferType offerType,
CancellationToken cancellationToken);
}
public class OfferValueCalculator : IOfferValueCalculator
Finally, I don't like class names with the same name as interfaces, and instead want to name the implementation based on what makes it different/special. This implementation uses an external web API, maybe use that? I rename the class to ExternalApiOfferValueCalculator
.
Refactoring to use this new interface
We're not quite done, because our handler class does not use this interface. First things first, on the private field's type declaration, I can bring up the ReSharper refactor dialog to select "Use Base Type Where Possible" option:
ReSharper brings up this refactoring when you're refactoring the Type's references. With this, ReSharper then asks what base type I want to use:
I pick the interface and now my field is converted to the interface:
public class AssignOfferHandler : IRequestHandler<AssignOfferRequest>
{
private readonly AppDbContext _appDbContext;
private readonly IOfferValueCalculator _offerValueCalculator;
public AssignOfferHandler(
AppDbContext appDbContext,
HttpClient httpClient)
{
_appDbContext = appDbContext;
_offerValueCalculator = new ExternalApiOfferValueCalculator(httpClient);
}
You can do this refactoring on any member's type declaration - fields, properties, method parameters, etc. Finally, I need to convert that incoming constructor parameter away from HttpClient
to IOfferValueCalculator
. Because this involves dependency injection configuration, it's not something a refactoring tool can fully complete itself. But first, I'll just get the class's signature correct. I highlight the set of code instantiating the value calculator and select the "Introduce Parameter" refactoring:
Note that ReSharper notices there are unused parameters that can be safely removed, so I'll select that incoming HttpClient
parameter as well. I do get a warning that ReSharper can't find usages of that constructor so things might get broken, but that's OK, we'll take care of that later, so let's proceed:
public class AssignOfferHandler : IRequestHandler<AssignOfferRequest>
{
private readonly AppDbContext _appDbContext;
private readonly IOfferValueCalculator _offerValueCalculator;
public AssignOfferHandler(
AppDbContext appDbContext, IOfferValueCalculator offerValueCalculator)
{
_appDbContext = appDbContext;
_offerValueCalculator = offerValueCalculator;
}
Hooray! Now I have a domain service IOfferValueCalculator
, and a concrete implementation. The DI configuration is straightforward, I can use the Typed Client pattern to add this interface/implementation:
public void ConfigureServices(IServiceCollection services)
{
services.AddMediatR(typeof(Startup));
services.AddHttpClient<IOfferValueCalculator, ExternalApiOfferValueCalculator>();
}
And with that, we've successfully introduced a domain service through refactoring.
In the next post, I'll take a look at the other methods and see where that logic could/should belong.