Domain services

We can go for a dependency to some external service, but we know that domain models should not have external dependencies, so how do we solve this issue? We can use a pattern called the Domain Service. In Domain-Driven Design domain services can perform different kinds of tasks and here we will look into one type of them.

Our domain service needs to check if a given country code is valid. The Money class will get it as a dependency, so we need to declare the domain service inside our domain model. Because we do not want to depend on anything on the outside of our domain model, we should not put any implementation details inside the domain model. It means that the only thing we are going to have inside the domain project is the domain service interface:

namespace Marketplace.Domain
{
public interface ICurrencyLookup
{
CurrencyDetails FindCurrency(string currencyCode);
}

public class CurrencyDetails : Value<CurrencyDetails>
{
public string CurrencyCode { get; set; }
public bool InUse { get; set; }
public int DecimalPlaces { get; set; }

public static CurrencyDetails None = new CurrencyDetails {
InUse = false};
}
}

The new interface will not just check if a given currency code can be matched with some currency. Since we already discussed that different currencies might have a different number of decimal places, the service will return an instance of CurrencyDetails class with this information included. If there will be no currency found for the given code, the service will return CurrencyDetails.None constant.

It is very common in C# that if a function is expected to return an instance of a reference type, it also can return null to indicate that there is no valid result that the function can produce. Although at first, this approach might seem easy, it creates massive problems. Our code becomes full of null checks because we suspect that every function can return null so we must trust no one to avoid a NullReferenceException.  Null has a specific null-type, and it is too easy to assign null to something that should never be null.

Sir Charles Antony Richard Hoare, better known as Tony Hoare, introduced null references to Algol programming language back in 1965. He remembers doing this "because it was so easy to implement." Much later, on QCon conference in London in 2009, he apologized for null reference saying "I call it my billion-dollar mistake."

Video: https://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare

In most functional languages null reference does not exist, also because it can easily break the functional composition. Instead, optional types are being used. In the code snippet above, we use the similar technique to return a pre-defined value that indicates that there is no currency found for a given code. This constant has proper type, proper name and we should never check the function output for null.

To mitigate the null reference issue, Microsoft decided to allow explicit declaration of nullable reference types. By default, reference types will be assumed as non-nullable. This feature will get to the next version of C#, and you can get more details about the proposal here: https://github.com/dotnet/csharplang/blob/master/proposals/nullable-reference-types.md

When the interface is there, we can now change our value object like to look like this:

using System;
using Marketplace.Framework;

namespace Marketplace.Domain
{
public class Money : Value<Money>
{
public static string DefaultCurrency = "EUR";

public static Money FromDecimal(
decimal amount, string currency,
ICurrencyLookup currencyLookup) =>
new Money(amount, currency, currencyLookup);

public static Money FromString(string amount, string currency,
ICurrencyLookup currencyLookup) =>
new Money(decimal.Parse(amount), currency, currencyLookup);

protected Money(decimal amount, string currencyCode,
ICurrencyLookup currencyLookup)
{
if (string.IsNullOrEmpty(currencyCode))
throw new ArgumentNullException(
nameof(currencyCode),
"Currency code must be specified");

var currency = currencyLookup.FindCurrency(currencyCode);
if (!currency.InUse)
throw new ArgumentException(
$"Currency {currencyCode} is not valid");

if (decimal.Round(
amount, currency.DecimalPlaces) != amount)
throw new ArgumentOutOfRangeException(
nameof(amount),
$"Amount in {
currencyCode} cannot have more than {
currency.DecimalPlaces} decimals");

Amount = amount;
Currency = currency;
}

private Money(decimal amount, CurrencyDetails currency)
{
Amount = amount;
Currency = currency;
}

public decimal Amount { get; }
public CurrencyDetails Currency { get; }

public Money Add(Money summand)
{
if (Currency != summand.Currency)
throw new CurrencyMismatchException(
"Cannot sum amounts with different currencies");

return new Money(Amount + summand.Amount, Currency);
}

public Money Subtract(Money subtrahend)
{
if (Currency != subtrahend.Currency)
throw new CurrencyMismatchException(
"Cannot subtract amounts with different currencies");

return new Money(Amount - subtrahend.Amount, Currency);
}

public static Money operator +(Money summand1, Money summand2)
=> summand1.Add(summand2);

public static Money operator -(Money minuend, Money subtrahend)
=> minuend.Subtract(subtrahend);

public override string ToString() => $"{
Currency.CurrencyCode} {Amount}";
}

public class CurrencyMismatchException : Exception
{
public CurrencyMismatchException(string message) :
base(message)
{
}
}
}

There are a couple of new things going on here.

  • We give the value object a dependency to the currency lookup domain service. Since we are using the interface, our domain model still has no external dependencies.
  • Since we are not using the null reference to indicate that there is no currency found for the specified code, we do not use null checks. Instead, we check if the returned currency is valid or not. Since the CurrencyDetails.NotFound constant has InUse property set to false; we will throw an exception just as we would do for any currency that exists but is not in use.
  • We do not use two as the maximum number of decimal places. Instead, we get this number from the currency lookup, so our value object became more flexible.
  • For our public methods, we need a simplified constructor, since these methods control that both operands have the same (valid) currency. Because we only trust our internals to use this constructor, it needs to be private. Both Add and Subtract methods use this constructor.
  • Added the ToString override to be able to see the human-readable value of the value object, for example in test results.

Our Money value object is still very much testable since we can  supply a fake currency lookup:

using System.Collections.Generic;
using System.Linq;
using Marketplace.Domain;

namespace Marketplace.Tests
{
public class FakeCurrencyLookup : ICurrencyLookup
{
private static readonly IEnumerable<CurrencyDetails> _currencies =
new[]
{
new CurrencyDetails
{
CurrencyCode = "EUR",
DecimalPlaces = 2,
InUse = true
},
new CurrencyDetails
{
CurrencyCode = "USD",
DecimalPlaces = 2,
InUse = true
},
new CurrencyDetails
{
CurrencyCode = "JPY",
DecimalPlaces = 0,
InUse = true
},
new CurrencyDetails
{
CurrencyCode = "DEM",
DecimalPlaces = 2,
InUse = false
}
};

public CurrencyDetails FindCurrency(string currencyCode)
{
var currency = _currencies.FirstOrDefault(x =>
x.CurrencyCode == currencyCode);
return currency ?? CurrencyDetails.None;
}
}
}

With this implementation in place, we can refactor the tests for Money like this:

using System;
using Marketplace.Domain;
using Xunit;

namespace Marketplace.Tests
{
public class Money_Spec
{
private static readonly ICurrencyLookup CurrencyLookup =
new FakeCurrencyLookup();

[Fact]
public void Two_of_same_amount_should_be_equal()
{
var firstAmount = Money.FromDecimal(5, "EUR", CurrencyLookup);
var secondAmount = Money.FromDecimal(5, "EUR", CurrencyLookup);

Assert.Equal(firstAmount, secondAmount);
}

[Fact]
public void Two_of_same_amount_but_different_Currencies_should_not_be_equal()
{
var firstAmount = Money.FromDecimal(5, "EUR", CurrencyLookup);
var secondAmount = Money.FromDecimal(5, "USD", CurrencyLookup);

Assert.NotEqual(firstAmount, secondAmount);
}


[Fact]
public void FromString_and_FromDecimal_should_be_equal()
{
var firstAmount = Money.FromDecimal(5, "EUR", CurrencyLookup);
var secondAmount = Money.FromString("5.00", "EUR", CurrencyLookup);

Assert.Equal(firstAmount, secondAmount);
}

[Fact]
public void Sum_of_money_gives_full_amount()
{
var coin1 = Money.FromDecimal(1, "EUR", CurrencyLookup);
var coin2 = Money.FromDecimal(2, "EUR", CurrencyLookup);
var coin3 = Money.FromDecimal(2, "EUR", CurrencyLookup);

var banknote = Money.FromDecimal(5, "EUR", CurrencyLookup);

Assert.Equal(banknote, coin1 + coin2 + coin3);
}

[Fact]
public void Unused_currency_should_not_be_allowed()
{
Assert.Throws<ArgumentException>(() =>
Money.FromDecimal(100, "DEM", CurrencyLookup)
);
}

[Fact]
public void Unknown_currency_should_not_be_allowed()
{
Assert.Throws<ArgumentException>(() =>
Money.FromDecimal(100, "WHAT?", CurrencyLookup)
);
}

[Fact]
public void Throw_when_too_many_decimal_places()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
Money.FromDecimal(100.123m, "EUR", CurrencyLookup)
);
}

[Fact]
public void Throws_on_adding_different_currencies()
{
var firstAmount = Money.FromDecimal(5, "USD", CurrencyLookup);
var secondAmount = Money.FromDecimal(5, "EUR", CurrencyLookup);

Assert.Throws<CurrencyMismatchException>(() =>
firstAmount + secondAmount
);
}

[Fact]
public void Throws_on_substracting_different_currencies()
{
var firstAmount = Money.FromDecimal(5, "USD", CurrencyLookup);
var secondAmount = Money.FromDecimal(5, "EUR", CurrencyLookup);

Assert.Throws<CurrencyMismatchException>(() =>
firstAmount - secondAmount
);
}
}
}

You can see here that we are testing some positive and some adverse scenarios to ensure those valid operations are correctly completed and also those invalid operations aren't allowed to be executed.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.149.240.196